@things-factory/headless-twin 10.0.12 → 10.0.14

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 (315) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +57 -2
  2. package/dist-server/engine/canonical-ingest.js +43 -2
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/command-routing.js +1 -1
  5. package/dist-server/engine/command-routing.js.map +1 -1
  6. package/dist-server/engine/index.d.ts +3 -0
  7. package/dist-server/engine/index.js +4 -0
  8. package/dist-server/engine/index.js.map +1 -1
  9. package/dist-server/engine/ingest-health.d.ts +376 -0
  10. package/dist-server/engine/ingest-health.js +482 -0
  11. package/dist-server/engine/ingest-health.js.map +1 -0
  12. package/dist-server/engine/integration-coverage.d.ts +76 -0
  13. package/dist-server/engine/integration-coverage.js +73 -0
  14. package/dist-server/engine/integration-coverage.js.map +1 -0
  15. package/dist-server/engine/integration-probes.d.ts +65 -0
  16. package/dist-server/engine/integration-probes.js +100 -0
  17. package/dist-server/engine/integration-probes.js.map +1 -0
  18. package/dist-server/engine/integration-runner.d.ts +45 -0
  19. package/dist-server/engine/integration-runner.js +59 -0
  20. package/dist-server/engine/integration-runner.js.map +1 -0
  21. package/dist-server/engine/integration-target-profile.d.ts +57 -0
  22. package/dist-server/engine/integration-target-profile.js +79 -0
  23. package/dist-server/engine/integration-target-profile.js.map +1 -0
  24. package/dist-server/engine/kpi-fold.d.ts +27 -0
  25. package/dist-server/engine/kpi-fold.js +47 -4
  26. package/dist-server/engine/kpi-fold.js.map +1 -1
  27. package/dist-server/engine/kpi-query.js +128 -14
  28. package/dist-server/engine/kpi-query.js.map +1 -1
  29. package/dist-server/engine/live-feed-registry.js +2 -16
  30. package/dist-server/engine/live-feed-registry.js.map +1 -1
  31. package/dist-server/engine/load-meter.d.ts +1 -1
  32. package/dist-server/engine/load-meter.js +12 -3
  33. package/dist-server/engine/load-meter.js.map +1 -1
  34. package/dist-server/engine/log.d.ts +18 -0
  35. package/dist-server/engine/log.js +80 -0
  36. package/dist-server/engine/log.js.map +1 -0
  37. package/dist-server/engine/loop-lag.d.ts +54 -0
  38. package/dist-server/engine/loop-lag.js +87 -0
  39. package/dist-server/engine/loop-lag.js.map +1 -0
  40. package/dist-server/engine/model-gap.d.ts +108 -0
  41. package/dist-server/engine/model-gap.js +95 -0
  42. package/dist-server/engine/model-gap.js.map +1 -0
  43. package/dist-server/engine/restart-policy.d.ts +3 -3
  44. package/dist-server/engine/restart-policy.js +4 -4
  45. package/dist-server/engine/restart-policy.js.map +1 -1
  46. package/dist-server/engine/runtime-key.js +1 -1
  47. package/dist-server/engine/runtime-key.js.map +1 -1
  48. package/dist-server/engine/stage-path.d.ts +83 -0
  49. package/dist-server/engine/stage-path.js +118 -0
  50. package/dist-server/engine/stage-path.js.map +1 -0
  51. package/dist-server/engine/twin-engine.d.ts +386 -16
  52. package/dist-server/engine/twin-engine.js +1217 -172
  53. package/dist-server/engine/twin-engine.js.map +1 -1
  54. package/dist-server/index.js +34 -2
  55. package/dist-server/index.js.map +1 -1
  56. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
  57. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
  58. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
  59. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
  60. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
  61. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
  62. package/dist-server/migrations/index.js +7 -1
  63. package/dist-server/migrations/index.js.map +1 -1
  64. package/dist-server/service/index.d.ts +5 -2
  65. package/dist-server/service/index.js +18 -7
  66. package/dist-server/service/index.js.map +1 -1
  67. package/dist-server/service/reference/discovery-result.d.ts +1 -1
  68. package/dist-server/service/reference/discovery-result.js +1 -1
  69. package/dist-server/service/reference/discovery-result.js.map +1 -1
  70. package/dist-server/service/reference/index.d.ts +4 -1
  71. package/dist-server/service/reference/index.js +6 -1
  72. package/dist-server/service/reference/index.js.map +1 -1
  73. package/dist-server/service/reference/reference-adapter.d.ts +210 -3
  74. package/dist-server/service/reference/reference-adapter.js +28 -4
  75. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  76. package/dist-server/service/reference/reference-assessment.d.ts +78 -0
  77. package/dist-server/service/reference/reference-assessment.js +156 -0
  78. package/dist-server/service/reference/reference-assessment.js.map +1 -0
  79. package/dist-server/service/reference/reference-live.d.ts +12 -1
  80. package/dist-server/service/reference/reference-live.js +134 -11
  81. package/dist-server/service/reference/reference-live.js.map +1 -1
  82. package/dist-server/service/reference/reference-master.d.ts +59 -3
  83. package/dist-server/service/reference/reference-master.js +34 -4
  84. package/dist-server/service/reference/reference-master.js.map +1 -1
  85. package/dist-server/service/reference/reference-probe.d.ts +92 -0
  86. package/dist-server/service/reference/reference-probe.js +186 -0
  87. package/dist-server/service/reference/reference-probe.js.map +1 -0
  88. package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
  89. package/dist-server/service/reference/reference-progress-subscription.js +94 -0
  90. package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
  91. package/dist-server/service/reference/reference-progress.d.ts +38 -0
  92. package/dist-server/service/reference/reference-progress.js +71 -0
  93. package/dist-server/service/reference/reference-progress.js.map +1 -0
  94. package/dist-server/service/reference/reference-resolver.js +210 -43
  95. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  96. package/dist-server/service/reference/twin-reference.d.ts +32 -0
  97. package/dist-server/service/reference/twin-reference.js +10 -0
  98. package/dist-server/service/reference/twin-reference.js.map +1 -1
  99. package/dist-server/service/twin-control/twin-control-mutation.js +4 -3
  100. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  101. package/dist-server/service/twin-event/journal-count.d.ts +21 -0
  102. package/dist-server/service/twin-event/journal-count.js +26 -0
  103. package/dist-server/service/twin-event/journal-count.js.map +1 -0
  104. package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
  105. package/dist-server/service/twin-event/twin-event-keys.js +15 -18
  106. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  107. package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
  108. package/dist-server/service/twin-event/twin-event-type.js +18 -1
  109. package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
  110. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  111. package/dist-server/service/twin-event/twin-event.js +9 -1
  112. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  113. package/dist-server/service/twin-forecast/twin-forecast-query.js +26 -2
  114. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  115. package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
  116. package/dist-server/service/twin-ingest-window/index.js +10 -0
  117. package/dist-server/service/twin-ingest-window/index.js.map +1 -0
  118. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
  119. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
  120. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
  121. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
  122. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
  123. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
  124. package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
  125. package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
  126. package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
  127. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  128. package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
  129. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  130. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
  131. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +72 -2
  132. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  133. package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
  134. package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
  135. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  136. package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
  137. package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
  138. package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
  139. package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
  140. package/dist-server/service/twin-model/epcis-coverage.js +46 -7
  141. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  142. package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
  143. package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
  144. package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
  145. package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
  146. package/dist-server/service/twin-model/isa95-coverage.js +108 -14
  147. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  148. package/dist-server/service/twin-model/item-ref.d.ts +24 -0
  149. package/dist-server/service/twin-model/item-ref.js +88 -0
  150. package/dist-server/service/twin-model/item-ref.js.map +1 -0
  151. package/dist-server/service/twin-model/name-index.d.ts +36 -0
  152. package/dist-server/service/twin-model/name-index.js +116 -0
  153. package/dist-server/service/twin-model/name-index.js.map +1 -0
  154. package/dist-server/service/twin-model/project-structure.js +1 -1
  155. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  156. package/dist-server/service/twin-model/status-tally.d.ts +9 -0
  157. package/dist-server/service/twin-model/status-tally.js +37 -0
  158. package/dist-server/service/twin-model/status-tally.js.map +1 -0
  159. package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
  160. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  161. package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
  162. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  163. package/dist-server/service/twin-model/twin-model-query.js +156 -4
  164. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  165. package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
  166. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  167. package/dist-server/service/twin-readiness/index.d.ts +2 -0
  168. package/dist-server/service/twin-readiness/index.js +6 -0
  169. package/dist-server/service/twin-readiness/index.js.map +1 -0
  170. package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
  171. package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
  172. package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
  173. package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
  174. package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
  175. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  176. package/dist-shared/entity-delta.d.ts +23 -3
  177. package/dist-shared/entity-delta.js +12 -8
  178. package/dist-shared/entity-delta.js.map +1 -1
  179. package/dist-shared/kpi-broadcast.js +1 -1
  180. package/dist-shared/kpi-broadcast.js.map +1 -1
  181. package/dist-shared/touched-items.d.ts +7 -0
  182. package/dist-shared/touched-items.js +80 -0
  183. package/dist-shared/touched-items.js.map +1 -0
  184. package/package.json +7 -7
  185. package/server/engine/canonical-ingest.ts +92 -7
  186. package/server/engine/command-routing.ts +1 -1
  187. package/server/engine/index.ts +4 -0
  188. package/server/engine/ingest-health.ts +782 -0
  189. package/server/engine/integration-coverage.ts +147 -0
  190. package/server/engine/integration-probes.ts +144 -0
  191. package/server/engine/integration-runner.ts +95 -0
  192. package/server/engine/integration-target-profile.ts +103 -0
  193. package/server/engine/kpi-fold.ts +72 -4
  194. package/server/engine/kpi-query.ts +129 -15
  195. package/server/engine/live-feed-registry.ts +2 -1
  196. package/server/engine/load-meter.ts +12 -3
  197. package/server/engine/log.ts +72 -0
  198. package/server/engine/loop-lag.ts +120 -0
  199. package/server/engine/model-gap.ts +168 -0
  200. package/server/engine/restart-policy.ts +4 -4
  201. package/server/engine/runtime-key.ts +1 -1
  202. package/server/engine/stage-path.ts +172 -0
  203. package/server/engine/twin-engine.ts +1334 -168
  204. package/server/index.ts +35 -2
  205. package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
  206. package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
  207. package/server/migrations/index.ts +7 -1
  208. package/server/service/index.ts +11 -0
  209. package/server/service/reference/discovery-result.ts +1 -1
  210. package/server/service/reference/index.ts +6 -1
  211. package/server/service/reference/reference-adapter.ts +220 -5
  212. package/server/service/reference/reference-assessment.ts +250 -0
  213. package/server/service/reference/reference-live.ts +143 -11
  214. package/server/service/reference/reference-master.ts +76 -6
  215. package/server/service/reference/reference-probe.ts +264 -0
  216. package/server/service/reference/reference-progress-subscription.ts +73 -0
  217. package/server/service/reference/reference-progress.ts +95 -0
  218. package/server/service/reference/reference-resolver.ts +200 -12
  219. package/server/service/reference/twin-reference.ts +39 -1
  220. package/server/service/twin-control/twin-control-mutation.ts +4 -3
  221. package/server/service/twin-event/journal-count.ts +53 -0
  222. package/server/service/twin-event/twin-event-keys.ts +16 -1
  223. package/server/service/twin-event/twin-event-type.ts +26 -2
  224. package/server/service/twin-event/twin-event.ts +7 -0
  225. package/server/service/twin-forecast/twin-forecast-query.ts +29 -2
  226. package/server/service/twin-ingest-window/index.ts +7 -0
  227. package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
  228. package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
  229. package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
  230. package/server/service/twin-instance/twin-instance.ts +1 -1
  231. package/server/service/twin-journal/twin-journal-query.ts +59 -5
  232. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +77 -3
  233. package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
  234. package/server/service/twin-model/axis-journal-evidence.ts +57 -0
  235. package/server/service/twin-model/epcis-coverage.ts +29 -8
  236. package/server/service/twin-model/iec61850-coverage.ts +2 -8
  237. package/server/service/twin-model/isa95-coverage.ts +107 -14
  238. package/server/service/twin-model/item-ref.ts +80 -0
  239. package/server/service/twin-model/name-index.ts +94 -0
  240. package/server/service/twin-model/project-structure.ts +1 -1
  241. package/server/service/twin-model/status-tally.ts +37 -0
  242. package/server/service/twin-model/twin-lineage-query.ts +38 -9
  243. package/server/service/twin-model/twin-model-item-query.ts +26 -27
  244. package/server/service/twin-model/twin-model-query.ts +155 -4
  245. package/server/service/twin-model/twin-model-tree-query.ts +7 -0
  246. package/server/service/twin-readiness/index.ts +3 -0
  247. package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
  248. package/server/service/twin-space/twin-space-resolver.ts +5 -4
  249. package/shared/entity-delta.ts +31 -7
  250. package/shared/kpi-broadcast.ts +1 -1
  251. package/shared/touched-items.ts +73 -0
  252. package/test/axis-journal-evidence.test.ts +71 -0
  253. package/test/axis-read.test.ts +101 -1
  254. package/test/boot-resume.test.ts +92 -26
  255. package/test/broadcast-cost-baseline.test.ts +200 -0
  256. package/test/broadcast-period.test.ts +58 -0
  257. package/test/canonical-ingest-vocabularies.test.ts +36 -1
  258. package/test/canonical-quantity-door.test.ts +61 -0
  259. package/test/checkpoint-refuses-empty.test.ts +103 -0
  260. package/test/command-routing.test.ts +1 -1
  261. package/test/cursor-stall-not-read-failure.test.ts +126 -0
  262. package/test/declared-location-types.test.ts +89 -0
  263. package/test/discovery-result.test.ts +1 -1
  264. package/test/duration-estimators.test.ts +1 -1
  265. package/test/entity-delta.test.ts +2 -2
  266. package/test/ingest-bench.test.ts +3 -3
  267. package/test/ingest-health-engine.test.ts +248 -0
  268. package/test/ingest-health-wiring.test.ts +119 -0
  269. package/test/ingest-health.test.ts +306 -0
  270. package/test/ingest-history.test.ts +247 -0
  271. package/test/integration-probes.test.ts +103 -0
  272. package/test/integration-runner.test.ts +95 -0
  273. package/test/item-ref.test.ts +78 -0
  274. package/test/journal-read-discipline.test.ts +177 -0
  275. package/test/journal-retention.test.ts +133 -0
  276. package/test/journal-sort-axis.test.ts +142 -0
  277. package/test/journal-write-door.test.ts +110 -0
  278. package/test/journal-write-trend.test.ts +115 -0
  279. package/test/kernel-kind-guard.test.ts +4 -4
  280. package/test/kpi-fold.test.ts +90 -3
  281. package/test/kpi-query-bench.test.ts +2 -2
  282. package/test/lineage-survives-restart.test.ts +17 -1
  283. package/test/live-cursor-wiring.test.ts +87 -0
  284. package/test/live-feed-registry.test.ts +9 -1
  285. package/test/live-kernel-facts.test.ts +7 -7
  286. package/test/live-mirror-parity.test.ts +6 -0
  287. package/test/load-meter.test.ts +29 -15
  288. package/test/local-declarations.test.ts +1 -1
  289. package/test/log-stamp.test.ts +59 -0
  290. package/test/loop-lag.test.ts +82 -0
  291. package/test/mirror-resumes-from-checkpoint.test.ts +192 -0
  292. package/test/model-gap.test.ts +153 -0
  293. package/test/oee-accumulator.test.ts +4 -0
  294. package/test/operational-vocabulary.test.ts +4 -4
  295. package/test/read-failure-visible.test.ts +145 -0
  296. package/test/reference-grounding.test.ts +70 -0
  297. package/test/resolve-ts-siblings.mjs +52 -0
  298. package/test/restart-policy.test.ts +7 -7
  299. package/test/revision-axis.test.ts +104 -0
  300. package/test/runtime-key.test.ts +2 -2
  301. package/test/scale-twin-bench.test.ts +2 -2
  302. package/test/spec-coverage.test.ts +1 -1
  303. package/test/stage-path.test.ts +95 -0
  304. package/test/standard-coverage.test.ts +15 -3
  305. package/test/status-tally.test.ts +55 -0
  306. package/test/structure-revision-db.test.ts +4 -0
  307. package/test/tenant-registry-db.test.ts +1 -1
  308. package/test/time-range.test.ts +152 -0
  309. package/test/touched-items.test.ts +61 -0
  310. package/test/twin-event-keys.test.ts +10 -2
  311. package/test/twin-model-item-db.test.ts +2 -0
  312. package/test/warm-start-seam.test.ts +4 -0
  313. package/test/yield-loop.test.ts +57 -2
  314. package/tsconfig.shared.tsbuildinfo +1 -1
  315. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,58 @@
1
+ /*
2
+ * 브로드캐스팅 주기는 **재 본 비용에 맞춘다** (2026-08-21).
3
+ *
4
+ * 한 번의 브로드캐스팅은 상태 크기에 비례한다(실측: 물품 2,400 개 트윈 하나가 4.5ms). 트윈이 스무 개면
5
+ * 200ms 주기에서 90ms 가 브로드캐스팅이고, 그 시간에는 HTTP 도 구독도 서지 못한다. 그래서 브로드캐스팅이 주기의
6
+ * 일정 몫을 넘게 쓰면 주기를 늘리고, 여유가 생기면 되돌린다.
7
+ *
8
+ * 지키는 것:
9
+ * ① 무거우면 늘린다 — 상한까지만.
10
+ * ② 가벼워지면 되돌린다 — 원래 주기 아래로는 내려가지 않는다.
11
+ * ③ 경계에서 늘리고 줄이기를 반복하지 않는다(문턱이 둘이다).
12
+ * ④ **늘렸다는 사실을 센다** — 조용히 늦추면 사람은 화면이 낡은 이유를 알 수 없다.
13
+ */
14
+ import { test, beforeEach } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+ import { createRequire } from 'node:module'
17
+
18
+ const req = createRequire(import.meta.url)
19
+ const { TwinEngine } = req('../dist-server/engine/twin-engine.js')
20
+
21
+ /** 주기 조정만 부른다 — 실제 브로드캐스팅(pubsub·DB)은 이 판정과 무관하다. */
22
+ const adjust = (flushMs: number) => (TwinEngine as any).adjustBroadcastPeriod(flushMs)
23
+
24
+ beforeEach(() => {
25
+ TwinEngine.broadcastPeriodMs = TwinEngine.BROADCAST_COALESCE_MS
26
+ TwinEngine.broadcastBackoffs = 0
27
+ })
28
+
29
+ test('브로드캐스팅이 주기의 몫을 넘게 쓰면 주기를 늘린다 — 상한까지만', () => {
30
+ const base = TwinEngine.BROADCAST_COALESCE_MS
31
+ adjust(base) // 주기를 통째로 쓴 셈
32
+ assert.equal(TwinEngine.broadcastPeriodMs, base * 2)
33
+
34
+ for (let i = 0; i < 10; i++) adjust(TwinEngine.broadcastPeriodMs)
35
+ assert.equal(TwinEngine.broadcastPeriodMs, TwinEngine.BROADCAST_MAX_COALESCE_MS, '상한 위로는 올라가지 않는다')
36
+ })
37
+
38
+ test('늘렸다는 사실을 센다 — 조용히 늦추지 않는다', () => {
39
+ adjust(TwinEngine.BROADCAST_COALESCE_MS)
40
+ assert.equal(TwinEngine.broadcastBackoffs, 1)
41
+ })
42
+
43
+ test('가벼워지면 되돌린다 — 원래 주기 아래로는 내려가지 않는다', () => {
44
+ adjust(TwinEngine.BROADCAST_COALESCE_MS) // 200 → 400
45
+ assert.ok(TwinEngine.broadcastPeriodMs > TwinEngine.BROADCAST_COALESCE_MS)
46
+
47
+ for (let i = 0; i < 10; i++) adjust(0.5)
48
+ assert.equal(TwinEngine.broadcastPeriodMs, TwinEngine.BROADCAST_COALESCE_MS)
49
+ })
50
+
51
+ test('경계에서 늘리고 줄이기를 반복하지 않는다 — 문턱이 둘이다', () => {
52
+ const base = TwinEngine.BROADCAST_COALESCE_MS
53
+ /* 늘리는 문턱(몫)과 되돌리는 문턱(몫의 절반) 사이의 값 — 어느 쪽도 아니어야 한다. */
54
+ const between = base * TwinEngine.BROADCAST_LOAD_RATIO * 0.75
55
+ for (let i = 0; i < 5; i++) adjust(between)
56
+ assert.equal(TwinEngine.broadcastPeriodMs, base, '문턱 사이의 값은 주기를 바꾸지 않는다')
57
+ assert.equal(TwinEngine.broadcastBackoffs, 0)
58
+ })
@@ -79,7 +79,7 @@ test('설비 에너지 상태는 EPCIS 도 계량도 아닌 자기 어휘로 들
79
79
  [
80
80
  { equipmentId: 'pv-1', at: '2026-08-15T09:00:00.000Z', generatedKW: 210 },
81
81
  { meterId: 'm-1', kW: 800, at: '2026-08-15T09:00:00.000Z' },
82
- { epc: 'urn:epc:id:sgtin:1.2.3', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing' }
82
+ { epc: 'urn:epc:id:sgtin:9521321.012345.3', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing' }
83
83
  ] as any,
84
84
  'tenant-1',
85
85
  '2026-08-15T09:00:00.000Z'
@@ -116,3 +116,38 @@ test('개폐 위치는 표준의 네 값만 받는다 — 과도·불량을 열
116
116
  assert.equal(bad.rejected.length, 1)
117
117
  assert.match(bad.rejected[0].errors.join(' '), /position/)
118
118
  })
119
+
120
+ /*
121
+ * ── 레코드가 말한 시각이 봉투의 시각이 된다 (2026-08-23) ────────────────────
122
+ *
123
+ * 이 경로는 `eventTimePath` 를 주지 않아 **언제나 폴링 시각**을 썼다. 그래서 커넥터가 이미 싣고 있던
124
+ * `eventTime` 이 조용히 무시됐다 — 어디에도 오류가 나지 않고, 커넥터를 붙인 쪽은 그것이 먹는다고
125
+ * 믿고 있었다.
126
+ *
127
+ * 무엇이 걸렸나: **계보의 시각**이다. 4월에 만든 로트가 8월에 만들어진 것으로 저널에 남았다. 그리고
128
+ * 실측 소요를 재는 폴드가 봉투 시각을 쓰므로(`kpi-fold`: `workMs = 완료 − 첫 착수`), 폴링 간격을
129
+ * 작업시간으로 배울 수 있었다 — 못 배우는 것보다 나쁘다.
130
+ */
131
+ test('레코드가 시각을 말하면 그것이 봉투의 시각이다 — 폴링 시각으로 덮지 않는다', () => {
132
+ const POLLED = '2026-08-23T00:00:00.000Z'
133
+ const SAID = '2026-04-15T07:35:53.000Z'
134
+ const { accepted, rejected } = ingestCanonicalRecords(
135
+ [{ ...epcisRecord, eventTime: SAID }] as any,
136
+ 't',
137
+ POLLED
138
+ )
139
+ assert.deepEqual(rejected, [])
140
+ assert.equal(accepted[0].eventTime, SAID, '원본이 말한 시각이어야 한다')
141
+ assert.notEqual(accepted[0].eventTime, POLLED, '폴링 시각이 아니어야 한다')
142
+ })
143
+
144
+ test('레코드가 시각을 말하지 않으면 폴링 시각으로 떨어진다 — 이 한 줄이 기존 경로를 막지 않는다', () => {
145
+ /*
146
+ * 커널의 경로 해석은 없는 값을 **오류로 만들지 않는다**(§`resolve`). 그래서 시각을 말하지 않는
147
+ * 레코드는 예전처럼 기본값을 쓴다. 이 단정이 없으면 위 한 줄이 조용히 대량 거부를 낼 수 있다.
148
+ */
149
+ const POLLED = '2026-08-23T00:00:00.000Z'
150
+ const { accepted, rejected } = ingestCanonicalRecords([epcisRecord] as any, 't', POLLED)
151
+ assert.deepEqual(rejected, [])
152
+ assert.equal(accepted[0].eventTime, POLLED)
153
+ })
@@ -0,0 +1,61 @@
1
+ /*
2
+ * ★ 비직렬 수량이 들어오는 문 — **조용히 사라지지 않는다.**
3
+ *
4
+ * `CanonicalRecord` 에 `quantityList` 자리를 냈다(담김의 `childQuantityList` 가 하루 먼저 같은 이유로
5
+ * 받은 자리다). 원본이 「이 자리에 이 품목이 얼마 남았다」를 말할 길이 없어서, 그것이 주된 경로인
6
+ * 시스템(식품 제조 MES)은 라이브를 낼 수 없었다.
7
+ *
8
+ * ── 배포 뒤 (2026-08-21) ────────────────────────────────────────────────────
9
+ * 커널 0.7.40 에 `ObjectEventMapping.quantityList` 가 들어와 **수량만 오는 레코드가 통과한다.**
10
+ * 그 전까지는 `epcList` 와 `quantityList` 가 둘 다 비어 검증이 거부했고, 이 시험은 그 거부가 **조용하지
11
+ * 않다**(이유와 함께 계수된다)는 것을 지켰다.
12
+ *
13
+ * 이제 통과 쪽으로 고정한다 — 통과와 거부를 둘 다 받아들이면 다시 막혔을 때 초록으로 남는다.
14
+ */
15
+ import { test } from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+
18
+ import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts'
19
+
20
+ const T0 = '2026-08-20T09:00:00.000Z'
21
+ const CLASS = 'https://chef.example.com/class/product-7f3a'
22
+
23
+ const qtyRecord = {
24
+ action: 'OBSERVE',
25
+ bizStep: 'urn:epcglobal:cbv:bizstep:storing',
26
+ disposition: 'urn:epcglobal:cbv:disp:sellable_accessible',
27
+ quantityList: [{ epcClass: CLASS, quantity: 3.5, uom: 'KGM' }],
28
+ readPoint: 'chef-wh:w1',
29
+ bizLocation: 'chef-wh:w1'
30
+ }
31
+
32
+ test('수량만 오는 레코드가 통과하고 수량이 봉투까지 실린다', () => {
33
+ const { accepted, rejected } = ingestCanonicalRecords(qtyRecord as any, 'plantA', T0)
34
+ assert.deepEqual(rejected, [], `거부됐다 — 커널에 quantityList 자리가 있는지 확인: ${JSON.stringify(rejected)}`)
35
+ assert.equal(accepted.length, 1)
36
+ /* 자리만 통과하고 값이 빠지면 더 나쁘다 — 실린 줄 알고 잃는다. */
37
+ const ev: any = (accepted[0] as any).data ?? (accepted[0] as any).event ?? accepted[0]
38
+ assert.deepEqual(ev.quantityList, [{ epcClass: CLASS, quantity: 3.5, uom: 'KGM' }])
39
+ assert.deepEqual(ev.epcList, [], '개체가 없으므로 빈 배열이다 — 지어내지 않는다')
40
+ })
41
+
42
+ test('개체 관측은 그대로 통과한다 — 문을 내면서 기존 길을 막지 않는다', () => {
43
+ const { accepted, rejected } = ingestCanonicalRecords(
44
+ { epc: 'urn:epc:id:sgtin:9521321.012345.1', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing', readPoint: 'chef-wh:w1' } as any,
45
+ 'plantA',
46
+ T0
47
+ )
48
+ assert.equal(rejected.length, 0, `개체 관측이 거부되면 안 된다: ${JSON.stringify(rejected)}`)
49
+ assert.equal(accepted.length, 1)
50
+ })
51
+
52
+ test('개체도 수량도 없으면 거부된다 — 무엇을 관측했는지 말하지 않았다', () => {
53
+ const { accepted, rejected } = ingestCanonicalRecords(
54
+ { action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing', readPoint: 'chef-wh:w1' } as any,
55
+ 'plantA',
56
+ T0
57
+ )
58
+ assert.equal(accepted.length, 0)
59
+ assert.equal(rejected.length, 1)
60
+ assert.ok(rejected[0].errors?.length, '이유 없이 버리지 않는다')
61
+ })
@@ -0,0 +1,103 @@
1
+ /*
2
+ * ★ **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** — 체크포인트가 좋은 스냅샷을 덮지 않게.
3
+ *
4
+ * ── 무엇이 손실을 영구화하고 있었나 (2026-08-24 실측) ────────────────────────
5
+ * 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를 버린다 —
6
+ * 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그 정정이 오지
7
+ * 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다.
8
+ *
9
+ * 실측(포천, `operato-chef-v1-pocheon`):
10
+ *
11
+ * 저널을 접으면 items 741 · orders 2,856 · tasks 9,250 · nowTime 2026-04-15
12
+ * 라이브 미러 items 0 · orders 0 · tasks 0 · nowTime 2026-01-01
13
+ * 저장된 스냅샷 rev 221,884 · items 741 · orders 2,780 · tasks 6,353 (어제 것)
14
+ * 그런데 이 판이 같은 키에 **빈 상태**를 쓰고 있었다
15
+ *
16
+ * 그러면 되살릴 캐시가 사라진다. 재기동이 상태를 버리는 것은 저널에서 되살릴 수 있지만, **캐시를
17
+ * 지우는 것은 그 복구 수단을 함께 지운다.**
18
+ *
19
+ * ── 왜 리비전으로는 막을 수 없나 ────────────────────────────────────────────
20
+ * 저널 줄 번호는 **관측이 없어도 자란다**(다른 축·다른 트윈의 사건, 체크포인트 자체). 그래서
21
+ * 「리비전이 더 크면 진전」이라는 규칙은 빈 상태를 통과시킨다.
22
+ *
23
+ * 막는 기준은 **「들은 것이 있나」**다. 유입이 한 건도 없었다면 그 빈 상태는 원본이 「비었다」고
24
+ * 말한 것이 아니라 **우리가 아무것도 못 들은 것**이다 — 그 둘을 같은 값으로 적으면 「모름」이
25
+ * 「없음」이 된다. 이 저장소가 여러 번 거절한 모양이다.
26
+ *
27
+ * 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다.
28
+ *
29
+ * ── 엔진 본체는 부를 수 없다 ────────────────────────────────────────────────
30
+ * DB 를 물고 있어 시험에서 부르지 못한다(§`snapshot-freshness` 와 같은 사정). 그래서 판정식만 떼어
31
+ * 시험하고, **그 식이 엔진에 실제로 있는지**를 소스로 함께 확인한다 — 식만 시험하면 엔진이 그것을
32
+ * 쓰지 않아도 초록이다.
33
+ */
34
+ import { test } from 'node:test'
35
+ import assert from 'node:assert/strict'
36
+ import { readFileSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+
39
+ /** 엔진의 조건과 같은 식(`twin-engine.persistSnapshot`). */
40
+ const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
41
+
42
+ const refuses = (live: any, stored: any, ingestedTotal: number): boolean =>
43
+ !(ingestedTotal > 0) && observed(live) === 0 && observed(stored) > 0
44
+
45
+ const RICH = { items: [{ epc: 'a' }], orders: [{ id: 'o' }], tasks: [{ id: 't' }] }
46
+ const EMPTY = { items: [], orders: [], tasks: [] }
47
+
48
+ test('못 들었고 라이브가 비었는데 저장된 것에 사실이 있으면 — **거절한다**', () => {
49
+ /* ★ 이 시험이 막는 것. 통과시키면 되살릴 유일한 사본이 사라진다. */
50
+ assert.equal(refuses(EMPTY, RICH, 0), true)
51
+ })
52
+
53
+ test('들은 것이 있으면 통과한다 — 원본이 「다 비었다」고 말한 것은 사실이다', () => {
54
+ /*
55
+ * 창고가 실제로 비는 것은 정상이다. 그때는 유입이 있었으므로 그 빈 상태가 **관측**이고,
56
+ * 여기서 막으면 스냅샷이 영구히 낡는다(고치려던 것의 반대 방향으로 틀린다).
57
+ */
58
+ assert.equal(refuses(EMPTY, RICH, 1), false)
59
+ assert.equal(refuses(EMPTY, RICH, 24839), false)
60
+ })
61
+
62
+ test('라이브에 사실이 있으면 통과한다 — 못 들었어도 심어진 상태는 지킬 값이다', () => {
63
+ assert.equal(refuses(RICH, RICH, 0), false)
64
+ })
65
+
66
+ test('저장된 것이 비어 있으면 막을 것이 없다 — 첫 체크포인트를 가로막지 않는다', () => {
67
+ /* 처음 뜬 트윈은 저장된 것이 없다. 여기서 막으면 그 트윈은 영원히 스냅샷을 갖지 못한다. */
68
+ assert.equal(refuses(EMPTY, EMPTY, 0), false)
69
+ assert.equal(refuses(EMPTY, null, 0), false)
70
+ assert.equal(refuses(EMPTY, undefined, 0), false)
71
+ })
72
+
73
+ test('축 하나만 남아도 사실이다 — 셋을 합쳐 센다', () => {
74
+ /*
75
+ * 오더만 남고 재고가 빈 상태는 흔하다(다 내보낸 창고). 축마다 따로 판정하면 그 상태가
76
+ * 「비었다」로 읽힌다.
77
+ */
78
+ assert.equal(refuses(EMPTY, { items: [], orders: [{ id: 'o' }], tasks: [] }, 0), true)
79
+ assert.equal(refuses(EMPTY, { items: [], orders: [], tasks: [{ id: 't' }] }, 0), true)
80
+ })
81
+
82
+ test('리비전은 판정에 쓰지 않는다 — 저널 번호는 관측이 없어도 자란다', () => {
83
+ /*
84
+ * ★ 이 단정이 **틀린 기준으로 돌아가는 것**을 막는다. 실측에서 저장된 스냅샷의 리비전은 221,884
85
+ * 였고 라이브는 그보다 큰 번호를 들고 있었다 — 리비전을 기준으로 삼으면 빈 상태가 통과한다.
86
+ */
87
+ const live = { ...EMPTY, revision: 999999 }
88
+ const stored = { ...RICH, revision: 1 }
89
+ assert.equal(refuses(live, stored, 0), true, '번호가 커도 사실이 없으면 덮지 않는다')
90
+ })
91
+
92
+ test('엔진이 실제로 이 판정을 한다 — 식만 시험하면 엔진이 안 써도 초록이다', () => {
93
+ const src = readFileSync(join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts'), 'utf8')
94
+ /* 주석을 걷어낸 코드만 — 「덮지 않는다」를 적어 둔 근거가 가드를 뒤집지 않게. */
95
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
96
+ const at = code.indexOf('persistSnapshot')
97
+ assert.ok(at > 0, 'persistSnapshot 이 있어야 한다')
98
+ const body = code.slice(at, at + 2000)
99
+ assert.match(body, /ingestedTotal/, '「들은 것이 있나」를 본다')
100
+ assert.match(body, /items\?\.length[\s\S]{0,80}orders\?\.length[\s\S]{0,80}tasks\?\.length/, '관측 축 셋을 합쳐 센다')
101
+ assert.match(body, /checkpoint refused/, '거절을 말한다 — 조용히 거절하지 않는다')
102
+ assert.doesNotMatch(body.slice(0, body.indexOf('checkpoint refused')), /revision >|revision </, '리비전 비교로 판정하지 않는다')
103
+ })
@@ -43,7 +43,7 @@ test('없는 인스턴스와 다른 도메인은 같은 사유로 거절한다
43
43
  assert.deepEqual(routeCommand(sim, false, 'attention.ack'), { target: 'reject', errorCode: 'no-instance' })
44
44
  })
45
45
 
46
- test('런타임이 없는 시뮬 — 예외를 던지지 않고 이유를 알린다', () => {
46
+ test('런타임이 없는 시뮬 — 오류를 내지 않고 이유를 알린다', () => {
47
47
  assert.deepEqual(routeCommand({ hasRuntime: false, hasKernelDispatch: true }, true, 'order.hold'), { target: 'reject', errorCode: 'no-runtime' })
48
48
  })
49
49
 
@@ -0,0 +1,126 @@
1
+ /*
2
+ * ★ **조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.**
3
+ *
4
+ * ── 무엇이 반대 방향을 가리켰나 (2026-08-24) ────────────────────────────────
5
+ * 2026-08-23 에 「원본에 닿지 못한다」를 트윈이 말할 수 있게 `onReadFailure` 를 만들었다. 그런데 그 문으로
6
+ * **다른 사실**이 함께 나가고 있었다.
7
+ *
8
+ * 원본에 닿지 못했다 접속 실패·시간 초과 → **기다리면 풀린다**
9
+ * 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행 → **기다려도 안 풀린다**
10
+ *
11
+ * 둘째는 **읽기가 성공한 실패**다. 원본은 답했고 그 답의 모양이 커서를 이겼다. 그런데 화면이
12
+ * 「원본에 닿지 못한다」고 말하면 사람은 원본을 의심하고 기다린다 — 실제로 필요한 조치는 페이지를
13
+ * 키우거나 같은 시각 안에서 순서를 정하는 것이고, 기다림으로는 영원히 풀리지 않는다.
14
+ *
15
+ * 게다가 그 문구가 스트림 이름을 꺼내는 규칙에 맞지 않아 **어느 표가 막혔는지도 남지 않았다.**
16
+ *
17
+ * ── 이 시험이 지키는 것 ─────────────────────────────────────────────────────
18
+ * ① 두 축이 **서로를 지우지 않는다** ② 두 축이 **각자 뷰로 나간다**(유입이 없는 갈래에서도)
19
+ * ③ 「몇 번·언제부터」를 정체 쪽도 든다 ④ 넘어가면 지운다
20
+ */
21
+ import { test } from 'node:test'
22
+ import assert from 'node:assert/strict'
23
+ import { readFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import {
26
+ clearCursorStall,
27
+ clearReadFailure,
28
+ ingestHealth,
29
+ newIngestLedger,
30
+ recordCursorStall,
31
+ recordIngest,
32
+ recordReadFailure
33
+ } from '../server/engine/ingest-health.ts'
34
+
35
+ const T0 = Date.parse('2026-08-24T06:00:00.000Z')
36
+
37
+ test('정체는 읽기 실패와 **다른 칸**이다 — 서로를 지우지 않는다', () => {
38
+ /*
39
+ * ★ 한 칸을 쓰면 나중에 온 것이 앞의 것을 덮는다. 그러면 「닿지 못한다」와 「못 넘어간다」 중
40
+ * 마지막 것만 남고, 화면은 조치를 반대로 안내할 수 있다.
41
+ */
42
+ const l = newIngestLedger()
43
+ recordReadFailure(l, 'connect ETIMEDOUT', T0, 'inventory')
44
+ recordCursorStall(l, 'one timestamp holds more rows than a page', T0 + 1000, 'trace')
45
+ assert.equal(l.readFailure?.reason, 'connect ETIMEDOUT', '읽기 실패가 남아 있다')
46
+ assert.equal(l.cursorStall?.reason, 'one timestamp holds more rows than a page')
47
+ assert.equal(l.readFailure?.stream, 'inventory')
48
+ assert.equal(l.cursorStall?.stream, 'trace', '어느 표가 막혔나가 조치의 절반이다')
49
+ })
50
+
51
+ test('각자 지워진다 — 하나가 풀렸다고 다른 하나를 지우지 않는다', () => {
52
+ const l = newIngestLedger()
53
+ recordReadFailure(l, 'ETIMEDOUT', T0)
54
+ recordCursorStall(l, 'cursor cannot advance', T0)
55
+ clearReadFailure(l)
56
+ assert.equal(l.readFailure, undefined, '다시 닿았다')
57
+ assert.ok(l.cursorStall, '그렇다고 커서가 넘어간 것은 아니다')
58
+ clearCursorStall(l)
59
+ assert.equal(l.cursorStall, undefined, '넘어갔으면 지운다')
60
+ })
61
+
62
+ test('「몇 번·언제부터」를 정체 쪽도 든다 — 한 번 밀린 것과 사흘째는 다르다', () => {
63
+ const l = newIngestLedger()
64
+ recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0)
65
+ recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 600_000)
66
+ recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 1_200_000)
67
+ assert.equal(l.cursorStall?.consecutive, 3)
68
+ assert.equal(l.cursorStall?.sinceMs, T0, '처음 막힌 시각을 지킨다')
69
+ assert.equal(l.cursorStall?.lastAtMs, T0 + 1_200_000)
70
+ /* 넘어간 뒤 다시 막히면 **새 정체**다 — 옛 시작 시각을 물려받지 않는다. */
71
+ clearCursorStall(l)
72
+ recordCursorStall(l, 'stuck again', T0 + 3_600_000)
73
+ assert.equal(l.cursorStall?.consecutive, 1)
74
+ assert.equal(l.cursorStall?.sinceMs, T0 + 3_600_000)
75
+ })
76
+
77
+ test('뷰로 나간다 — **유입이 없는 갈래에서도**', () => {
78
+ /*
79
+ * ★ 여기가 가장 조용해지기 쉬운 자리다. 커서가 막히면 유입이 0이므로 장부가 「빈 장부」 갈래를
80
+ * 지나고, 그 갈래에서 이 값을 떨어뜨리면 **정체가 실제로 일어나는 바로 그 상황에서** 침묵한다.
81
+ * `unhandled` 를 스냅샷이 떨어뜨려 정합성 검사가 영원히 조용했던 것과 같은 부류다.
82
+ */
83
+ const l = newIngestLedger()
84
+ recordCursorStall(l, 'one timestamp holds more rows than a page', T0, 'trace')
85
+ const v = ingestHealth(l, 'attached', undefined, T0 + 60_000)
86
+ assert.ok(v.cursorStall, '유입이 없어도 뷰에 실린다')
87
+ assert.equal(v.cursorStall?.consecutive, 1)
88
+ assert.equal(v.cursorStall?.since, new Date(T0).toISOString())
89
+ assert.equal(v.cursorStall?.stream, 'trace')
90
+ assert.equal(v.readFailure, undefined, '닿지 못한 것은 아니다 — 섞이지 않는다')
91
+ })
92
+
93
+ test('유입이 있는 갈래에서도 나간다 — 두 갈래를 다 채운다', () => {
94
+ /* 갈래가 둘인 함수에서 한쪽만 채우는 실수를 이 저장소가 이미 겪었다. */
95
+ const l = newIngestLedger()
96
+ recordIngest(l, 5, [], T0)
97
+ recordCursorStall(l, 'cursor cannot advance', T0 + 1000, 'lot')
98
+ const v = ingestHealth(l, 'attached', undefined, T0 + 2000)
99
+ assert.equal(v.total.offered, 5, '유입이 있는 갈래다 — 빈 비교로 통과하지 않는다')
100
+ assert.equal(v.cursorStall?.stream, 'lot')
101
+ })
102
+
103
+ test('막히지 않았으면 그 칸을 만들지 않는다 — 빈 값이 「막혔다」로 읽히지 않게', () => {
104
+ const l = newIngestLedger()
105
+ recordIngest(l, 3, [], T0)
106
+ assert.equal(ingestHealth(l, 'attached', undefined, T0 + 1000).cursorStall, undefined)
107
+ })
108
+
109
+ test('두 사실이 같은 문으로 나가지 않는다 — 배선을 소스로 확인한다', () => {
110
+ /*
111
+ * 장부만 시험하면 **호스트가 여전히 읽기 실패 문으로 보내도** 초록이다. 실제로 그랬던 것이 이 결함의
112
+ * 시작이었다.
113
+ */
114
+ const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8')
115
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
116
+ assert.match(code, /onCursorStall:/, '어댑터의 정체 통로를 잇는다')
117
+ assert.match(code, /recordIngestCursorStall/, '정체는 정체 장부로 간다')
118
+ /* 그리고 그 문구가 「닿지 못한다」와 달라야 한다 — 같은 말이면 화면이 여전히 반대로 안내한다. */
119
+ const at = code.indexOf('onCursorStall:')
120
+ const body = code.slice(at, at + 900)
121
+ assert.match(body, /cursor cannot advance/, '무엇이 막혔는지 말한다')
122
+ assert.match(body, /Waiting will not clear this/, '기다림이 답이 아니라고 말한다')
123
+ assert.doesNotMatch(body, /recordIngestReadFailure/, '읽기 실패 장부로 보내지 않는다')
124
+ /* 재부착의 자격(`status`)은 여기서도 건드리지 않는다 — 2026-08-23 에 되돌린 그 규율. */
125
+ assert.doesNotMatch(body, /status:/, '정체가 피드를 영구히 끄지 않는다')
126
+ })
@@ -0,0 +1,89 @@
1
+ /*
2
+ * ★ 자리 타입을 **누구에게 묻는가** — 원본이 선언했으면 그 선언이 정본이다.
3
+ *
4
+ * ── 무엇이 틀렸나 (2026-08-20) ──────────────────────────────────────────────
5
+ * 호스트는 「이 자리 타입을 아는가」를 **내장 카탈로그**에게만 물었다(`DOMAIN_CATALOG[system]`).
6
+ * 그런데 원본은 자기 `DomainDefinition.locationTypes` 를 함께 보낼 수 있고, 그러면 **그 선언이
7
+ * 정본**이다 — 카탈로그는 우리가 아는 도메인들의 기본 어휘일 뿐이다.
8
+ *
9
+ * 상대가 틀렸으니 결과가 이랬다: 자기 어휘를 선언한 원본(예: F&B 라인 — 배합·가열·세척·금속검출·
10
+ * 소분·포장)은 **정상적인 자리마다 경고를 하나씩** 받는다. 경고가 소음이 되면 사람은 그것을 읽지 않게
11
+ * 되고, 그때 **진짜 경고도 함께 죽는다.**
12
+ *
13
+ * ── 지키는 것 ───────────────────────────────────────────────────────────────
14
+ * ① 선언이 있으면 선언에 대조한다 ② 선언이 없으면 카탈로그로 떨어진다 ③ 선언을 **못 읽은 것**과
15
+ * 「아무것도 선언하지 않은 것」을 가른다(못 읽었으면 카탈로그로 떨어진다 — 빈 목록으로 읽으면 모든
16
+ * 자리가 경고를 받는다) ④ 아무 데도 없는 타입은 여전히 경고한다.
17
+ */
18
+ import { test } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import { createRequire } from 'node:module'
21
+
22
+ const req = createRequire(import.meta.url)
23
+ const { masterToTwin, declaredLocationTypes } = req('../dist-server/service/reference/reference-master.js')
24
+
25
+ /** F&B 라인 — 내장 MES 카탈로그(트레일러 제조)에는 없는 어휘. */
26
+ const FNB = ['mixing', 'cooking', 'washing', 'metal-detect', 'portioning', 'packing']
27
+
28
+ const CATALOG = { mes: { locationTypes: ['raw-store', 'cut-station', 'fg-store'] } }
29
+
30
+ const master = (types: string[], productionSpec?: unknown) => ({
31
+ source: 'chef-1',
32
+ system: 'mes',
33
+ siteName: '식품 공장',
34
+ /* `masterToTwin` 이 공간에서 spaceId 를 꺼낸다 — 최소 모양을 준다. */
35
+ space: { spaceId: 'chef-site', width: 60, depth: 30, unit: 'm' },
36
+ locations: types.map((t, i) => ({ id: `loc-${i}`, type: t, role: 'location' as const })),
37
+ equipment: [],
38
+ ...(productionSpec ? { productionSpec } : {})
39
+ })
40
+
41
+ const specWith = (keys: string[]) => ({ definition: { locationTypes: keys.map(k => ({ key: k, label: k })) } })
42
+
43
+ const unknownWarnings = (r: any) => (r.warnings ?? []).filter((w: any) => w.code === 'unknown-location-type')
44
+
45
+ test('원본이 선언했으면 그 선언에 대조한다 — 정상적인 자리에 경고를 주지 않는다', () => {
46
+ const r = masterToTwin(master(FNB, specWith(FNB)), CATALOG)
47
+
48
+ assert.deepEqual(unknownWarnings(r), [], 'F&B 여섯 자리 모두 선언돼 있다')
49
+ })
50
+
51
+ test('선언이 없으면 카탈로그로 떨어진다 — 예전 거동 그대로', () => {
52
+ const r = masterToTwin(master(['raw-store', 'mixing']), CATALOG)
53
+ const w = unknownWarnings(r)
54
+
55
+ assert.equal(w.length, 1, '카탈로그에 없는 하나만')
56
+ assert.equal(w[0].params.type, 'mixing')
57
+ })
58
+
59
+ test('선언에도 카탈로그에도 없으면 경고한다 — 진짜 경고는 죽이지 않는다', () => {
60
+ const r = masterToTwin(master([...FNB, 'ghost-station'], specWith(FNB)), CATALOG)
61
+ const w = unknownWarnings(r)
62
+
63
+ assert.equal(w.length, 1)
64
+ assert.equal(w[0].params.type, 'ghost-station')
65
+ })
66
+
67
+ test('선언을 못 읽으면 카탈로그로 떨어진다 — 빈 목록으로 읽지 않는다', () => {
68
+ /*
69
+ * 「없는 것」과 「못 읽은 것」은 다르다. 못 읽은 것을 빈 목록으로 읽으면 **모든 자리가 경고**를 받고,
70
+ * 그것이 바로 이 시험이 막으려는 소음이다.
71
+ */
72
+ for (const broken of [{}, { definition: {} }, { definition: { locationTypes: [] } }, { definition: { locationTypes: 'nope' } }]) {
73
+ assert.equal(declaredLocationTypes(broken), undefined, `못 읽은 모양: ${JSON.stringify(broken)}`)
74
+ }
75
+
76
+ const r = masterToTwin(master(['raw-store', 'mixing'], { definition: { locationTypes: [] } }), CATALOG)
77
+ assert.equal(unknownWarnings(r).length, 1, '카탈로그 기준으로 하나만 경고한다')
78
+ })
79
+
80
+ test('선언 목록의 모양을 너그럽게 읽는다 — 문자열 목록도 받는다', () => {
81
+ assert.deepEqual(declaredLocationTypes({ definition: { locationTypes: ['mixing', 'packing'] } }), ['mixing', 'packing'])
82
+ assert.deepEqual(declaredLocationTypes({ definition: { locationTypes: [{ key: 'mixing' }, { nope: 1 }] } }), ['mixing'])
83
+ })
84
+
85
+ test('카탈로그도 선언도 없으면 아무 경고도 하지 않는다 — 모르는 것을 문제로 만들지 않는다', () => {
86
+ const r = masterToTwin(master(FNB), undefined)
87
+
88
+ assert.deepEqual(unknownWarnings(r), [], '견줄 상대가 없으면 판단하지 않는다')
89
+ })
@@ -65,7 +65,7 @@ test('타임아웃은 초 단위로 알린다 — 얼마를 기다렸는지 알
65
65
  assert.match(e.message, /15s/)
66
66
  })
67
67
 
68
- test('어댑터가 던진 것에서 사유를 뽑는다 — 형태를 가정하지 않는다', () => {
68
+ test('어댑터가 낸 오류에서 사유를 뽑는다 — 형태를 가정하지 않는다', () => {
69
69
  assert.equal(detailOf(new Error('ECONNREFUSED')), 'ECONNREFUSED')
70
70
  assert.equal(detailOf('404 Not Found'), '404 Not Found')
71
71
  /* 무엇이 왔는지 모르면 그렇게 말한다 — 빈 문장으로 "이유 없음" 처럼 보이게 하지 않는다. */
@@ -123,7 +123,7 @@ test('p50 이 0 이면 쓰지 않는다 — 0 초 작업은 측정 실패다', (
123
123
  assert.deepEqual(r.learned, {})
124
124
  })
125
125
 
126
- test('그룹이 없으면 빈 결과 — 던지지 않는다', () => {
126
+ test('그룹이 없으면 빈 결과 — 오류를 내지 않는다', () => {
127
127
  assert.equal(buildMeasuredEstimator(undefined).estimator, undefined)
128
128
  assert.equal(buildMeasuredEstimator([]).estimator, undefined)
129
129
  })
@@ -118,7 +118,7 @@ test('물품 집약: 상한을 넘으면 잘린 사실을 밝힌다 — 조용
118
118
  assert.equal(agg.items.length, ITEMS_AGGREGATE_LIMIT)
119
119
  assert.equal(agg.total, ITEMS_AGGREGATE_LIMIT + 7)
120
120
  assert.equal(agg.truncated, 7)
121
- /* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만 재방송한다). */
121
+ /* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만 재브로드캐스팅한다). */
122
122
  assert.equal(d.filter(x => x.data?.entity === 'item').length, ITEMS_AGGREGATE_LIMIT + 7)
123
123
  })
124
124
 
@@ -234,7 +234,7 @@ test('수요 구간·피크·계약은 집약 태그로 나간다 — 개체의
234
234
  assert.ok(agg, '집약 태그가 있어야 한다')
235
235
  assert.equal(agg.data.contractKW, 4000)
236
236
  assert.equal(agg.data.peakSince.kW, 4200)
237
- /* 하루치를 매 방송에 실으면 채널이 그것으로 찬다 — 최근 것만 내고 전체 수를 함께 낸다. */
237
+ /* 하루치를 매 브로드캐스팅에 실으면 채널이 그것으로 찬다 — 최근 것만 내고 전체 수를 함께 낸다. */
238
238
  assert.equal(agg.data.recent.length, 8)
239
239
  assert.equal(agg.data.closedTotal, 137, '자른 사실을 조용히 두지 않는다')
240
240
  })
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * 커널 인제스트 인터페이스 부하 실측(가정 아닌 측정) — Face1/커널 동기 인터페이스의 처리량 특성.
3
- * apply(이벤트 흡수) · snapshot(전체 투영) · buildEntityDeltas(방송 payload, 't1') 의 비용을 규모별로 잰다.
3
+ * apply(이벤트 흡수) · snapshot(전체 투영) · buildEntityDeltas(브로드캐스팅 payload, 't1') 의 비용을 규모별로 잰다.
4
4
  * 결론은 design/integration/ingest-scale.md §커널 인터페이스에 스펙으로 반영.
5
5
  * (순수 커널+호스트 매핑, shell 무관 → node:test.)
6
6
  */
@@ -56,7 +56,7 @@ test('bench: buildEntityDeltas 비용 vs 엔티티 수 (전 엔티티 매번 방
56
56
  }
57
57
  })
58
58
 
59
- test('bench: 방송 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규모', () => {
59
+ test('bench: 브로드캐스팅 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규모', () => {
60
60
  for (const [locations, items] of [[200, 5000], [1000, 50000], [2000, 100000]]) {
61
61
  const proj = new StateProjector(model(locations))
62
62
  for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i % locations}`))
@@ -64,6 +64,6 @@ test('bench: 방송 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규
64
64
  const t0 = performance.now()
65
65
  for (let k = 0; k < iters; k++) buildEntityDeltas(proj.snapshot(), 't1')
66
66
  const per = (performance.now() - t0) / iters
67
- console.log(` [방송1회] ${locations.toLocaleString()} locations / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 방송/s`)
67
+ console.log(` [브로드캐스팅1회] ${locations.toLocaleString()} locations / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 브로드캐스팅/s`)
68
68
  }
69
69
  })