@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,153 @@
1
+ /*
2
+ * 첫 봉투가 몇 번부터 시작했는지 남기는가.
3
+ *
4
+ * ── 무엇이 있었나 (2026-09-06 실측, 인티그레이션 레인이 찾음) ────────────────
5
+ * plant 아웃박스의 1~20 이 400 으로 거절돼 그 연결에 커서가 없었다. 다음에 온 21번이 첫 봉투로
6
+ * 앉으며 커서가 21 로 뛰었다.
7
+ *
8
+ * 커서가 0 이었다면 같은 21이 구멍이었다. 둘의 차이가 그 결함의 전부다. 스무 건이 구멍으로도 안
9
+ * 세어지고, 경고도 없고, backfill 대상도 아니었다 — backfill 은 커서부터 묻는다.
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+
14
+ import { firstSeqOf, seqOf, takeInSequence, withSeq } from '../server/service/reference/hook-contract.js'
15
+
16
+ const item = (seq: number) => ({ seq, record: { at: '2026-09-06T00:00:00.000Z' } })
17
+
18
+ test('★ 커서가 없는 연결에 21번이 오면 그 앞 20건을 못 봤다고 말한다', () => {
19
+ const got = takeInSequence({ items: [item(21)], scope: 'EVENTS', lastSeq: undefined })
20
+
21
+ assert.equal(got.firstUnseenBefore, 20, '못 본 개수를 안 말하면 그 사실이 아무 데도 안 남는다')
22
+ assert.equal(got.advancedSeq, 21)
23
+ assert.equal(got.gap, undefined, '첫 봉투를 구멍으로 만들지 않는다 — 거절할 근거가 없다')
24
+ })
25
+
26
+ test('★ 커서가 0 이면 같은 21번이 구멍이다 — 둘의 차이가 이 결함의 전부다', () => {
27
+ const got = takeInSequence({ items: [item(21)], scope: 'EVENTS', lastSeq: 0 })
28
+
29
+ assert.equal(got.gap?.expectedSeq, 1)
30
+ assert.equal(got.firstUnseenBefore, undefined, '구멍은 첫 봉투가 아니다')
31
+ })
32
+
33
+ test('1번부터 시작한 연결은 못 본 것이 없다 — 그 칸을 내지 않는다', () => {
34
+ const got = takeInSequence({ items: [item(1)], scope: 'EVENTS', lastSeq: undefined })
35
+
36
+ assert.equal(got.firstUnseenBefore, undefined, '0 을 내면 화면이 말할 것 없는 줄을 그린다')
37
+ assert.equal(got.advancedSeq, 1)
38
+ })
39
+
40
+ test('늦게 붙은 연결도 수만 말한다 — 손실이라고 하지 않는다', () => {
41
+ /*
42
+ * 원본이 오래 돌고 있는데 트윈을 나중에 붙이면 seq 4,712 부터 받는 것이 정상이고, 그 아래
43
+ * 4,711 개는 애초에 안 받기로 한 것이다. 계약은 둘을 구별할 수 없으므로 수만 낸다.
44
+ */
45
+ const got = takeInSequence({ items: [item(4712)], scope: 'EVENTS', lastSeq: undefined })
46
+ assert.equal(got.firstUnseenBefore, 4711)
47
+ assert.equal(got.advancedSeq, 4712, '늦게 붙은 것을 거절하지 않는다')
48
+ })
49
+
50
+ test('이어받는 봉투에는 그 칸이 없다', () => {
51
+ const got = takeInSequence({ items: [item(49)], scope: 'EVENTS', lastSeq: 48 })
52
+ assert.equal(got.firstUnseenBefore, undefined)
53
+ assert.equal(got.advancedSeq, 49)
54
+ })
55
+
56
+ test('★ 이 테스트가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
57
+ const first = takeInSequence({ items: [item(21)], scope: 'EVENTS', lastSeq: undefined })
58
+ const cont = takeInSequence({ items: [item(21)], scope: 'EVENTS', lastSeq: 20 })
59
+ assert.notEqual(first.firstUnseenBefore, cont.firstUnseenBefore, '첫 봉투와 이어받은 봉투가 같아졌다')
60
+ })
61
+
62
+ /* ── 몇 번부터 받기 시작했나를 커서에 적는다 ────────────────────────────────── */
63
+
64
+ test('★ 첫 봉투의 번호를 커서에 적는다 — 「앞이 비었나」를 나중에 답하려면 이 값이 있어야 한다', () => {
65
+ const got = takeInSequence({ items: [item(21)], scope: 'EVENTS', lastSeq: undefined })
66
+ assert.equal(got.firstSeq, 21)
67
+
68
+ const cursor = withSeq({}, 'mes-line-a::EVENTS', got.advancedSeq!, got.firstSeq)
69
+ assert.equal(firstSeqOf(cursor, 'mes-line-a::EVENTS'), 21)
70
+ assert.equal(seqOf(cursor, 'mes-line-a::EVENTS'), 21)
71
+ })
72
+
73
+ test('★ 한 번 적힌 firstSeq 는 안 바뀐다 — 덮으면 「앞이 비었나」를 다시 못 묻는다', () => {
74
+ /*
75
+ * 커서가 나아간 뒤에 이 값을 만들면 그 번호는 첫 번호가 아니라 그때 마침 온 번호다.
76
+ */
77
+ let cursor = withSeq({}, 'S', 21, 21)
78
+ cursor = withSeq(cursor, 'S', 22, 22)
79
+ cursor = withSeq(cursor, 'S', 48, 48)
80
+
81
+ assert.equal(firstSeqOf(cursor, 'S'), 21, 'firstSeq 가 덮였다')
82
+ assert.equal(seqOf(cursor, 'S'), 48)
83
+ })
84
+
85
+ test('★ 적힌 적이 없으면 모른다고 말한다 — 0 으로 답하면 1번부터 다 받은 것으로 읽힌다', () => {
86
+ const old = withSeq({}, 'S', 48)
87
+ assert.equal(firstSeqOf(old, 'S'), undefined)
88
+ assert.equal(seqOf(old, 'S'), 48, '옛 커서의 번호는 그대로 읽힌다')
89
+ })
90
+
91
+ test('이어받는 봉투는 firstSeq 를 안 낸다 — 그 값을 만들 근거가 없다', () => {
92
+ const got = takeInSequence({ items: [item(49)], scope: 'EVENTS', lastSeq: 48 })
93
+ assert.equal(got.firstSeq, undefined)
94
+ })
95
+
96
+ test('1번부터 시작한 연결도 firstSeq 를 적는다 — 못 본 것이 없다는 사실도 사실이다', () => {
97
+ const got = takeInSequence({ items: [item(1)], scope: 'EVENTS', lastSeq: undefined })
98
+ assert.equal(got.firstSeq, 1)
99
+ assert.equal(got.firstUnseenBefore, undefined, '못 본 것이 0이면 그 칸은 안 낸다')
100
+ })
101
+
102
+ test('★ 밑에서부터 다시 받으면 그 번호가 firstSeq 가 된다 — from: 0 은 first 판정이 아니다', () => {
103
+ /*
104
+ * 실제로 났다(2026-09-06). `fillTwinScope(from: 0)` 으로 잃은 구간을 되찾았는데 커서에
105
+ * `firstSeq` 가 안 적혔다. 커서가 0 이라 seq 1 이 `next` 판정이었고 `first` 가 아니었기 때문이다.
106
+ *
107
+ * 그런데 우리는 실제로 1번부터 갖게 됐다. `lowestSeq` 가 그 사실을 낸다.
108
+ */
109
+ const got = takeInSequence({ items: [item(1), item(2), item(3)], scope: 'EVENTS', lastSeq: 0 })
110
+
111
+ assert.equal(got.firstSeq, undefined, '커서가 있었으므로 첫 봉투가 아니다')
112
+ assert.equal(got.lowestSeq, 1, '받아들인 것 중 가장 낮은 번호')
113
+ assert.equal(got.advancedSeq, 3)
114
+ })
115
+
116
+ test('가장 낮은 번호는 하나도 못 받으면 없다', () => {
117
+ const got = takeInSequence({ items: [item(9)], scope: 'EVENTS', lastSeq: 4 })
118
+ assert.equal(got.lowestSeq, undefined, '구멍이라 아무것도 안 받았다')
119
+ assert.equal(got.gap?.expectedSeq, 5)
120
+ })
121
+
122
+ /* ── 봉투의 시각 ────────────────────────────────────────────────────────────── */
123
+
124
+ test('★ 시각을 안 싣는 레코드에 봉투의 시각을 얹는다 — 안 얹으면 다시 받을 때마다 새 사실이 된다', () => {
125
+ /*
126
+ * [측정함 2026-09-06] `fillTwinScope(from: 0)` 을 두 번 돌렸더니 mes-line-a 저널이 40 → 69건이
127
+ * 되고 중복이 27가지 생겼다. 시각을 안 싣는 레코드는 ingest 시각으로 떨어지는데 그 값이 실행할
128
+ * 때마다 달라서 fact identity 가 매번 바뀐다. 시각을 말하는 12건만 되풀이로 걸러졌다.
129
+ *
130
+ * plant 아웃박스는 그 시각을 봉투에 실어 보내고 있었다. 갈 자리가 없어서 버려지던 값이다.
131
+ */
132
+ const got = takeInSequence({
133
+ items: [{ seq: 1, record: { subjectId: 'L', decision: 'scrap' }, at: '2026-09-01T00:00:00.000Z' }],
134
+ scope: 'EVENTS',
135
+ lastSeq: 0
136
+ })
137
+ assert.equal((got.records[0] as any).at, '2026-09-01T00:00:00.000Z')
138
+ })
139
+
140
+ test('★ 레코드가 자기 시각을 말하면 그것이 이긴다 — 봉투는 「언제 보냈나」에 가깝다', () => {
141
+ const got = takeInSequence({
142
+ items: [{ seq: 1, record: { subjectId: 'L', decision: 'scrap', at: '2026-08-30T00:00:00.000Z' }, at: '2026-09-01T00:00:00.000Z' }],
143
+ scope: 'EVENTS',
144
+ lastSeq: 0
145
+ })
146
+ assert.equal((got.records[0] as any).at, '2026-08-30T00:00:00.000Z')
147
+ })
148
+
149
+ test('봉투가 시각을 안 말하면 레코드를 그대로 둔다 — 없는 값을 만들지 않는다', () => {
150
+ const rec = { subjectId: 'L', decision: 'scrap' }
151
+ const got = takeInSequence({ items: [{ seq: 1, record: rec }], scope: 'EVENTS', lastSeq: 0 })
152
+ assert.equal(got.records[0], rec, '새 객체를 만들 이유가 없다')
153
+ })
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Folding a span that crosses a daylight-saving transition.
3
+ *
4
+ * ── Why this is worth a test ───────────────────────────────────────────────
5
+ * `utcOffsetMinutes` on the model is one number, resolved when the host starts the twin.
6
+ * Folding walks a past span, so a year of facts gets read with whatever offset happened to
7
+ * be current at start — and a year holds two transitions. Facts after one land an hour off
8
+ * and are counted into the neighbouring shift. Nothing errors; shift performance is simply
9
+ * wrong, and plausible.
10
+ *
11
+ * `America/Tijuana` is already in this deployment's data (PST -480 / PDT -420), so this is
12
+ * not a hypothetical.
13
+ */
14
+ import test from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+
17
+ import { foldKpi } from '../server/engine/kpi-fold.js'
18
+
19
+ /* Two shifts that between them cover the day, so a misread hour lands in the other one. */
20
+ const SHIFTS = [
21
+ { id: 'day', fromTime: '06:00', toTime: '18:00' },
22
+ { id: 'night', fromDayOffset: 0, fromTime: '18:00', toDayOffset: 1, toTime: '06:00' }
23
+ ] as any[]
24
+
25
+ /* Tijuana: PDT (-420) in July, PST (-480) in December. */
26
+ const SUMMER = Date.UTC(2026, 6, 15, 13, 30) // 06:30 local under -420 → day shift
27
+ const WINTER = Date.UTC(2026, 11, 15, 13, 30) // 05:30 local under -480 → night shift
28
+
29
+ /*
30
+ * The same rule the host uses (`utcOffsetOf`), written out here because that function lives
31
+ * beside code the test runner cannot load. Kept small and checked by its own test below, so a
32
+ * drift in it does not quietly turn the rest of this file green.
33
+ */
34
+ function offsetAt(atMs: number): number | undefined {
35
+ const name = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Tijuana', timeZoneName: 'longOffset' })
36
+ .formatToParts(new Date(atMs))
37
+ .find(p => p.type === 'timeZoneName')?.value
38
+
39
+ const m = /^GMT([+-])(\d{1,2}):(\d{2})$/.exec(name ?? '')
40
+
41
+ return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : undefined
42
+ }
43
+
44
+ /*
45
+ * A completed task is a transition, not one row — the fold counts throughput at completion
46
+ * and needs the earlier states to have a lead time to measure.
47
+ */
48
+ const doneAt = (ms: number, id: string): any[] =>
49
+ ['created', 'in-progress', 'completed'].map((status, i) => ({
50
+ eventType: 'task.status',
51
+ eventTime: new Date(ms - (2 - i) * 30_000).toISOString(),
52
+ payload: { data: { taskId: id, status } }
53
+ }))
54
+
55
+ function shiftsOf(events: any[], options: any) {
56
+ const out = foldKpi(events, { fromMs: Date.UTC(2026, 0, 1), toMs: Date.UTC(2027, 0, 1) } as any, {
57
+ groupBy: 'shift',
58
+ shifts: SHIFTS,
59
+ ...options
60
+ } as any)
61
+
62
+ return (out as any)?.groups?.items?.map((g: any) => g.key) ?? []
63
+ }
64
+
65
+ test('the probe itself reads Tijuana as -420 in July and -480 in December', () => {
66
+ /* If this drifts, the rest of the file is measuring nothing. */
67
+ assert.equal(offsetAt(SUMMER), -420)
68
+ assert.equal(offsetAt(WINTER), -480)
69
+ })
70
+
71
+ test('a fixed offset puts a December fact in the wrong shift — the defect', () => {
72
+ /*
73
+ * Both facts are 13:30 UTC. Read with the summer offset the December one looks like
74
+ * 06:30 local and lands on the day shift, when locally it was 05:30 and belongs to the
75
+ * night shift.
76
+ */
77
+ const keys = shiftsOf([...doneAt(WINTER, 'w')], { utcOffsetMinutes: -420 })
78
+
79
+ assert.deepEqual(keys, ['day'])
80
+ })
81
+
82
+ test('offsetAt resolves per fact, so the December fact lands on the night shift', () => {
83
+ const keys = shiftsOf([...doneAt(WINTER, 'w')], { utcOffsetMinutes: -420, offsetAt })
84
+
85
+ assert.deepEqual(keys, ['night'])
86
+ })
87
+
88
+ test('the same span read per instant splits across both shifts, not one', () => {
89
+ const keys = shiftsOf([...doneAt(SUMMER, 's'), ...doneAt(WINTER, 'w')], { utcOffsetMinutes: -420, offsetAt }).sort()
90
+
91
+ assert.deepEqual(keys, ['day', 'night'])
92
+ })
93
+
94
+ test('offsetAt returning undefined falls back to the number rather than to UTC', () => {
95
+ /*
96
+ * Not knowing the offset for an instant is not the same as the instant being UTC.
97
+ * Falling through to the declared number keeps the previous behaviour instead of
98
+ * silently asserting Greenwich.
99
+ */
100
+ const keys = shiftsOf([...doneAt(WINTER, 'w')], { utcOffsetMinutes: -420, offsetAt: () => undefined })
101
+
102
+ assert.deepEqual(keys, ['day'])
103
+ })
104
+
105
+ test('no offset at all still folds, reading times as UTC as the contract states', () => {
106
+ const keys = shiftsOf([...doneAt(WINTER, 'w')], {})
107
+
108
+ /* 13:30 UTC is inside 06:00-18:00. */
109
+ assert.deepEqual(keys, ['day'])
110
+ })
@@ -0,0 +1,90 @@
1
+ /*
2
+ * **설비는 자리에도 구역에도 설 수 있다.**
3
+ *
4
+ * ── 실물 (2026-09-06) ───────────────────────────────────────────────────────
5
+ * `EQ-CAM-SHOWER-01`(에어샤워)이 `LINE-CAM` 이라는 **구역**에 딸려 있다. 라인 전체에 붙는 설비는
6
+ * 자리 하나를 고를 수 없고, 그것이 정상이다.
7
+ *
8
+ * 그런데 인제스트가 그 참조를 **「끊어졌다」고 경고**했다. 자리 목록에서만 찾았기 때문이다.
9
+ *
10
+ * ```
11
+ * unresolved-reference
12
+ * equipment.homeLocation points at something that is not in this twin (EQ-CAM-SHOWER-01→LINE-CAM)
13
+ * ```
14
+ *
15
+ * 계약이 그 갈림을 위해 `homeLocationKind = 'location' | 'area'` 를 두었는데 **아무도 안 채우고
16
+ * 있었다** — 선언만 있고 읽는 쪽도 쓰는 쪽도 0곳이었다.
17
+ *
18
+ * 짐작하지 않는다. 마스터가 이미 `role` 로 자리와 구역을 가르고 있으므로 그것을 읽는다.
19
+ */
20
+ import { test } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+
23
+ import { masterToTwin } from '../server/service/reference/reference-master.ts'
24
+
25
+ const master = (): any => ({
26
+ source: 'm',
27
+ system: 'mes',
28
+ siteName: 'S',
29
+ space: { spaceId: 'm-site', width: 100, depth: 60, unit: 'm' },
30
+ locations: [
31
+ { id: 'ST-1', role: 'location', type: 'weld-station', capacity: 1 },
32
+ { id: 'LINE-CAM', role: 'area', type: 'line' }
33
+ ],
34
+ equipment: [
35
+ { id: 'EQ-WELD-1', kind: 'welder', homeLocationId: 'ST-1' },
36
+ { id: 'EQ-CAM-SHOWER-01', kind: 'air-shower', homeLocationId: 'LINE-CAM' }
37
+ ]
38
+ })
39
+
40
+ const eq = (model: any, id: string) => model.equipment.find((e: any) => e.id === id)
41
+
42
+ test('★ 구역에 선 설비는 homeLocationKind 로 그것을 말한다', () => {
43
+ const { model } = masterToTwin(master())
44
+ const shower = eq(model, 'EQ-CAM-SHOWER-01')
45
+
46
+ assert.equal(shower.homeLocation, 'LINE-CAM')
47
+ assert.equal(shower.homeLocationKind, 'area', '구역인 것을 안 말하면 읽는 쪽이 자리로 읽는다')
48
+ })
49
+
50
+ test('★ 자리에 선 설비는 그 칸을 안 만든다 — 계약의 기본값이 location 이다', () => {
51
+ /*
52
+ * 없는 칸을 만들면 화면이 그것을 그리고, 「자리」라는 당연한 말이 모든 줄에 붙는다.
53
+ * `homeOf()` 가 없을 때 `'location'` 을 내는 것이 그래서다.
54
+ */
55
+ const { model } = masterToTwin(master())
56
+ const welder = eq(model, 'EQ-WELD-1')
57
+
58
+ assert.equal(welder.homeLocation, 'ST-1')
59
+ assert.equal('homeLocationKind' in welder, false)
60
+ })
61
+
62
+ test("★ role 이 'both' 면 자리다 — 구역으로 만들지 않는다", () => {
63
+ /*
64
+ * `both` 는 그 줄이 자리 목록에도 구역 목록에도 들어간다는 뜻이다. 설비가 그것을 가리키면
65
+ * **자리 쪽이 이긴다** — 자리는 설비가 실제로 서는 지점이고, 구역은 그것을 담는 넓이다.
66
+ */
67
+ const m = master()
68
+ m.locations = [{ id: 'CELL-1', role: 'both', type: 'cell', capacity: 1 }]
69
+ m.equipment = [{ id: 'EQ-1', kind: 'welder', homeLocationId: 'CELL-1' }]
70
+
71
+ const { model } = masterToTwin(m)
72
+ assert.equal('homeLocationKind' in eq(model, 'EQ-1'), false)
73
+ })
74
+
75
+ test('기준 자리가 없는 설비는 그 칸도 없다', () => {
76
+ const m = master()
77
+ m.equipment = [{ id: 'EQ-FREE', kind: 'agv' }]
78
+ const { model } = masterToTwin(m)
79
+ assert.equal('homeLocationKind' in eq(model, 'EQ-FREE'), false)
80
+ })
81
+
82
+ test('★ 이 테스트가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => {
83
+ const { model } = masterToTwin(master())
84
+ assert.equal(model.equipment.length, 2, '설비를 하나도 안 읽었다')
85
+ assert.notEqual(
86
+ eq(model, 'EQ-CAM-SHOWER-01').homeLocationKind,
87
+ eq(model, 'EQ-WELD-1').homeLocationKind,
88
+ '구역에 선 설비와 자리에 선 설비가 같아졌다'
89
+ )
90
+ })
@@ -0,0 +1,86 @@
1
+ /*
2
+ * 떨어진 것에 봉투 id 가 실리나.
3
+ *
4
+ * ── 왜 필요한가 (2026-09-07 측정) ───────────────────────────────────────────
5
+ * 훅 응답의 떨어진 목록이 `{ record, errors }` 였고, plant 은 그것을 **봉투 id 문자열 목록**으로
6
+ * 읽고 있었다.
7
+ *
8
+ * ```
9
+ * headless-twin rejected: { record, errors }[] 객체 배열
10
+ * plant "rejected is a list of envelope ids" 문자열 배열
11
+ * ```
12
+ *
13
+ * `Array.isArray` 는 통과하니 그쪽 guard 도 안 걸렸다. id 를 맞추는데 객체라 하나도 안 맞고
14
+ * **묶음 전체가 「보냈음」으로 찍혔다** — 422 와 `ok: false` 를 보냈는데 재시도 0 · 오류 없음이었다.
15
+ *
16
+ * 우리가 말했는데 상대가 못 알아듣는 모양이면, 말한 것이 아니다.
17
+ *
18
+ * 이 시험은 **모양**을 본다. 값이 흐르는지는 다른 시험이 보고, 여기서는 보내는 쪽이 자기 행을 찾을 수
19
+ * 있는 열쇠가 실리는지만 본다.
20
+ */
21
+ import test from 'node:test'
22
+ import assert from 'node:assert/strict'
23
+
24
+ import { rejectedForCaller } from '../server/service/reference/hook-rejected.js'
25
+
26
+ const bad = { orderId: 'WO-1' }
27
+ const worse = { orderId: 'WO-2' }
28
+ const ERRORS = ['order: at 없음/형식 오류']
29
+
30
+ test('봉투 id 를 아는 것은 id 로 나간다 — 보내는 쪽이 그 행만 표시할 수 있다', () => {
31
+ const out = rejectedForCaller([{ record: bad, errors: ERRORS }], [{ record: bad, eventId: 'evt-1' }])
32
+
33
+ assert.deepEqual(out, [{ eventId: 'evt-1', errors: ERRORS }])
34
+ })
35
+
36
+ test('id 를 모르면 레코드를 그대로 싣는다 — 빈 id 를 넣지 않는다', () => {
37
+ /*
38
+ * 빈 id 를 실으면 보내는 쪽이 아무 행도 못 찾으면서 「알았다」고 여긴다. 그때는 무엇이 떨어졌는지
39
+ * 보여 주는 것이 유일한 답이다.
40
+ */
41
+ const out = rejectedForCaller([{ record: bad, errors: ERRORS }], [{ record: bad }])
42
+
43
+ assert.deepEqual(out, [{ record: bad, errors: ERRORS }])
44
+ })
45
+
46
+ test('짝은 객체 동일성으로 짓는다 — 같은 모양의 레코드 둘이 서로의 id 를 가져가지 않는다', () => {
47
+ const twinA = { orderId: 'SAME' }
48
+ const twinB = { orderId: 'SAME' }
49
+ const out = rejectedForCaller(
50
+ [{ record: twinB, errors: ERRORS }],
51
+ [
52
+ { record: twinA, eventId: 'evt-A' },
53
+ { record: twinB, eventId: 'evt-B' }
54
+ ]
55
+ )
56
+
57
+ assert.deepEqual(out, [{ eventId: 'evt-B', errors: ERRORS }], '값으로 비교하면 evt-A 가 나온다')
58
+ })
59
+
60
+ test('여러 건이 각자의 id 로 나간다', () => {
61
+ const out = rejectedForCaller(
62
+ [
63
+ { record: bad, errors: ERRORS },
64
+ { record: worse, errors: ['다른 까닭'] }
65
+ ],
66
+ [
67
+ { record: bad, eventId: 'evt-1' },
68
+ { record: worse, eventId: 'evt-2' }
69
+ ]
70
+ )
71
+
72
+ assert.deepEqual(out, [
73
+ { eventId: 'evt-1', errors: ERRORS },
74
+ { eventId: 'evt-2', errors: ['다른 까닭'] }
75
+ ])
76
+ })
77
+
78
+ test('빈 문자열·공백 id 는 없는 것으로 본다 — 아무 행도 못 찾는 열쇠다', () => {
79
+ assert.deepEqual(rejectedForCaller([{ record: bad, errors: ERRORS }], [{ record: bad, eventId: ' ' }]), [
80
+ { record: bad, errors: ERRORS }
81
+ ])
82
+ })
83
+
84
+ test('떨어진 것이 없으면 빈 목록이다', () => {
85
+ assert.deepEqual(rejectedForCaller([], [{ record: bad, eventId: 'evt-1' }]), [])
86
+ })
@@ -0,0 +1,193 @@
1
+ /*
2
+ * Does the reply's own arithmetic close.
3
+ *
4
+ * ── Why this exists (2026-09-07) ───────────────────────────────────────────
5
+ * The contract now declares the response a sender reads, and the reason it had to is that a
6
+ * sender guessed instead: operato-plant read `rejected` as a list of envelope id strings, the
7
+ * receiver sent `{record, errors}`, `Array.isArray` passed on both, and a batch of three was
8
+ * stamped delivered while one envelope never landed (mes_outbox_events seq 178 — SENT,
9
+ * attempts 0, no error).
10
+ *
11
+ * `offered` is the field that makes that catchable without reading the rejection list at all:
12
+ *
13
+ * offered === applied + duplicates + rejected.length + vocabulary
14
+ *
15
+ * ── What this file is really guarding ──────────────────────────────────────
16
+ * The first version of that sum left `vocabulary` out, and it was wrong. Vocabulary elements are
17
+ * counted in `offered` and deliberately kept out of the other three — they set state and must
18
+ * never reach the journal, because they carry no time axis. So every batch carrying one would
19
+ * have failed a sender's check, the warning would have been ignored within a week, and the real
20
+ * loss would have been ignored with it.
21
+ *
22
+ * The integration lane found that by looking for a counterexample rather than a confirmation,
23
+ * and had a measured one: 32 distinct facts reconciled as 29 journal + 2 rejected + 1 vocabulary.
24
+ *
25
+ * The count already existed — `liveIngest` has returned `vocabulary` all along — and this
26
+ * boundary dropped it, because the callback was typed as `IngestResult` and the field was not on
27
+ * it. A declared field nobody fills is the same defect one layer over, so what is asserted here
28
+ * is that a number handed to `ingest` arrives in the reply, and that the sum closes on a batch
29
+ * where it is not zero.
30
+ */
31
+ import { test } from 'node:test'
32
+ import assert from 'node:assert/strict'
33
+
34
+ import { WEBHOOK_STATUS } from '@operato/ops-contract'
35
+
36
+ import { handleHook, type HookStore } from '../server/service/reference/reference-hook.js'
37
+ import { registerAdapterType, getAdapter } from '../server/service/reference/reference-adapter.js'
38
+
39
+ const DOMAIN = 'd-1'
40
+ const SOURCE = 'sum-source'
41
+ const TWIN = 'twin-a'
42
+ const SECRET = 'shared-secret'
43
+
44
+ /** Hands every item straight through — this file is about the counts, not about translation. */
45
+ const ADAPTER_TYPE = 'test-sum-push'
46
+ if (!getAdapter(ADAPTER_TYPE)) {
47
+ registerAdapterType({
48
+ type: ADAPTER_TYPE,
49
+ label: 'sum push (test)',
50
+ handleInbound: (_cfg: any, _site: any, body: any) => ({ items: body?.items ?? [] })
51
+ } as any)
52
+ }
53
+
54
+ function makeStore(): HookStore {
55
+ const row = {
56
+ id: 'ref-1',
57
+ source: SOURCE,
58
+ adapterType: ADAPTER_TYPE,
59
+ connectionConfig: { hookSecret: SECRET },
60
+ scopeSpec: { produced: [{ siteId: 'site-a', instanceId: TWIN }] }
61
+ }
62
+ return {
63
+ load: async (domainId: string, source: string) => (domainId === DOMAIN && source === SOURCE ? row : null),
64
+ saveCursor: async () => {}
65
+ } as any
66
+ }
67
+
68
+ /** `count` unnumbered items — no `scope`, so sequence checking stays out of the way. */
69
+ function items(count: number) {
70
+ return Array.from({ length: count }, (_v, i) => ({ record: { moverId: `eq-${i}`, kind: 'press', status: 'idle' } }))
71
+ }
72
+
73
+ function push(count: number, ingest: any) {
74
+ return handleHook({
75
+ domainId: DOMAIN,
76
+ source: SOURCE,
77
+ instanceId: TWIN,
78
+ headers: { 'x-twin-hook-secret': SECRET },
79
+ body: { items: items(count) },
80
+ nowMs: Date.parse('2026-09-07T02:00:00.000Z'),
81
+ ingest,
82
+ store: makeStore()
83
+ })
84
+ }
85
+
86
+ /** What the sender is told to check. Written once so every case below reads the same way. */
87
+ function closes(body: any) {
88
+ return body.offered === body.applied + body.duplicates + (body.rejected?.length ?? 0) + body.vocabulary
89
+ }
90
+
91
+ test('the sum closes when everything was applied', async () => {
92
+ const out = await push(3, (_id: string, r: unknown[]) => ({ applied: r.length, duplicates: 0, rejected: [] }))
93
+
94
+ assert.equal(out.status, WEBHOOK_STATUS.ok)
95
+ assert.equal(out.body.offered, 3)
96
+ assert.equal(out.body.vocabulary, 0)
97
+ assert.ok(closes(out.body), JSON.stringify(out.body))
98
+ })
99
+
100
+ test('the sum closes when a batch carried vocabulary — this is the case that failed', async () => {
101
+ /*
102
+ * Three offered: two events seated, one vocabulary element. Without `vocabulary` in the reply
103
+ * the sender sees `3 - 2 - 0 - 0 = 1` and calls it a loss, on traffic that is entirely correct.
104
+ */
105
+ const out = await push(3, () => ({ applied: 2, duplicates: 0, rejected: [], vocabulary: 1 }))
106
+
107
+ assert.equal(out.body.offered, 3)
108
+ assert.equal(out.body.applied, 2)
109
+ assert.equal(out.body.vocabulary, 1, 'the count exists in liveIngest; this boundary must carry it')
110
+ assert.ok(closes(out.body), JSON.stringify(out.body))
111
+ })
112
+
113
+ test('a batch that is only vocabulary is not read as a dead twin', async () => {
114
+ /*
115
+ * This is what the sum test found, and it was a live defect rather than a missing field.
116
+ *
117
+ * The 503 branch inferred "the twin is not running live" from applied, duplicates and rejected
118
+ * all being zero. A vocabulary-only batch is exactly that shape and yet everything in it
119
+ * landed, so the receiver answered 503 to correct traffic — and a 503 means hold and retry, so
120
+ * the sender would have re-sent that batch every thirty seconds indefinitely.
121
+ *
122
+ * An absence is not a cause. The fourth term is what tells the two apart.
123
+ */
124
+ const out = await push(2, () => ({ applied: 0, duplicates: 0, rejected: [], vocabulary: 2 }))
125
+
126
+ assert.equal(out.status, WEBHOOK_STATUS.ok, 'zero events applied is not a twin that stopped')
127
+ assert.equal(out.body.applied, 0)
128
+ assert.equal(out.body.vocabulary, 2)
129
+ assert.ok(closes(out.body), 'zero applied is not zero received')
130
+ })
131
+
132
+ test('a dead twin is still reported as one — the fourth term did not loosen the check', async () => {
133
+ /*
134
+ * Breaking the case above must not cost the case it was guarding. Nothing applied and no
135
+ * vocabulary either is the real "nothing landed", and it has to keep answering 503: a 200 there
136
+ * would have the sender mark those facts sent and drop them.
137
+ */
138
+ const out = await push(2, () => ({ applied: 0, duplicates: 0, rejected: [], vocabulary: 0 }))
139
+
140
+ assert.equal(out.status, WEBHOOK_STATUS.notLive)
141
+ assert.equal(out.body.retryAfterMs, 30_000, 'hold and come back, do not drop')
142
+ })
143
+
144
+ test('the sum closes on a partial — the rejected list is a term in it', async () => {
145
+ const out = await push(3, (_id: string, r: unknown[]) => ({
146
+ applied: 1,
147
+ duplicates: 0,
148
+ rejected: [{ record: r[1], errors: ['no inputQuantityList'] }],
149
+ vocabulary: 1
150
+ }))
151
+
152
+ assert.equal(out.status, WEBHOOK_STATUS.partial, '422 is what a sender must not resend')
153
+ assert.equal(out.body.rejected?.length, 1)
154
+ assert.ok(closes(out.body), JSON.stringify(out.body))
155
+ })
156
+
157
+ test('duplicates are a term too — a resend is normal here, not a loss', async () => {
158
+ const out = await push(4, () => ({ applied: 1, duplicates: 3, rejected: [] }))
159
+
160
+ assert.equal(out.body.duplicates, 3)
161
+ assert.ok(closes(out.body))
162
+ })
163
+
164
+ test('an ingest that says nothing about vocabulary still reports the term', async () => {
165
+ /*
166
+ * `vocabulary` is optional on `IngestResult` so a caller that does not count it need not claim
167
+ * none. The reply must still carry the term: a field that appears only sometimes leaves the
168
+ * sender unable to tell "none" from "this receiver does not count it", which is the same
169
+ * ambiguity that made `rejected` guessable.
170
+ */
171
+ const out = await push(2, () => ({ applied: 2, duplicates: 0, rejected: [] }))
172
+
173
+ assert.equal(out.body.vocabulary, 0)
174
+ assert.ok('vocabulary' in (out.body as any))
175
+ })
176
+
177
+ test('seq 178 as it would read now — three offered, two applied', async () => {
178
+ /*
179
+ * The real batch: one of the three had no `inputQuantityList` and the kernel was right to
180
+ * refuse it. What went wrong was that the sender could not read the refusal, so it marked all
181
+ * three sent. The sum alone says otherwise, whatever shape the rejection list is in.
182
+ */
183
+ const out = await push(3, (_id: string, r: unknown[]) => ({
184
+ applied: 2,
185
+ duplicates: 0,
186
+ rejected: [{ record: r[2], errors: ['transformation: inputQuantityList is empty'] }]
187
+ }))
188
+
189
+ assert.equal(out.body.offered, 3)
190
+ assert.equal(out.body.applied, 2)
191
+ assert.equal(out.body.offered - out.body.applied - out.body.duplicates - out.body.vocabulary, 1)
192
+ assert.ok(closes(out.body), 'and the missing one is accounted for by the rejected list')
193
+ })