@things-factory/headless-twin 10.0.12 → 10.0.13

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 (312) 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 +335 -0
  10. package/dist-server/engine/ingest-health.js +434 -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 +352 -16
  52. package/dist-server/engine/twin-engine.js +1037 -166
  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 +184 -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 +68 -0
  77. package/dist-server/service/reference/reference-assessment.js +136 -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 +116 -11
  81. package/dist-server/service/reference/reference-live.js.map +1 -1
  82. package/dist-server/service/reference/reference-master.d.ts +47 -1
  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 +2 -1
  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 +19 -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 +23 -9
  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 +704 -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 +1135 -162
  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 +197 -5
  212. package/server/service/reference/reference-assessment.ts +215 -0
  213. package/server/service/reference/reference-live.ts +123 -11
  214. package/server/service/reference/reference-master.ts +64 -4
  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 +2 -1
  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 +2 -8
  236. package/server/service/twin-model/iec61850-coverage.ts +2 -8
  237. package/server/service/twin-model/isa95-coverage.ts +22 -9
  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/command-routing.test.ts +1 -1
  260. package/test/declared-location-types.test.ts +89 -0
  261. package/test/discovery-result.test.ts +1 -1
  262. package/test/duration-estimators.test.ts +1 -1
  263. package/test/entity-delta.test.ts +2 -2
  264. package/test/ingest-bench.test.ts +3 -3
  265. package/test/ingest-health-engine.test.ts +248 -0
  266. package/test/ingest-health-wiring.test.ts +119 -0
  267. package/test/ingest-health.test.ts +306 -0
  268. package/test/ingest-history.test.ts +247 -0
  269. package/test/integration-probes.test.ts +103 -0
  270. package/test/integration-runner.test.ts +95 -0
  271. package/test/item-ref.test.ts +78 -0
  272. package/test/journal-read-discipline.test.ts +177 -0
  273. package/test/journal-retention.test.ts +133 -0
  274. package/test/journal-sort-axis.test.ts +142 -0
  275. package/test/journal-write-door.test.ts +110 -0
  276. package/test/journal-write-trend.test.ts +115 -0
  277. package/test/kernel-kind-guard.test.ts +4 -4
  278. package/test/kpi-fold.test.ts +90 -3
  279. package/test/kpi-query-bench.test.ts +2 -2
  280. package/test/lineage-survives-restart.test.ts +17 -1
  281. package/test/live-cursor-wiring.test.ts +87 -0
  282. package/test/live-feed-registry.test.ts +9 -1
  283. package/test/live-kernel-facts.test.ts +7 -7
  284. package/test/live-mirror-parity.test.ts +6 -0
  285. package/test/load-meter.test.ts +29 -15
  286. package/test/local-declarations.test.ts +1 -1
  287. package/test/log-stamp.test.ts +59 -0
  288. package/test/loop-lag.test.ts +82 -0
  289. package/test/model-gap.test.ts +153 -0
  290. package/test/oee-accumulator.test.ts +4 -0
  291. package/test/operational-vocabulary.test.ts +4 -4
  292. package/test/read-failure-visible.test.ts +145 -0
  293. package/test/reference-grounding.test.ts +70 -0
  294. package/test/resolve-ts-siblings.mjs +52 -0
  295. package/test/restart-policy.test.ts +7 -7
  296. package/test/revision-axis.test.ts +93 -0
  297. package/test/runtime-key.test.ts +2 -2
  298. package/test/scale-twin-bench.test.ts +2 -2
  299. package/test/spec-coverage.test.ts +1 -1
  300. package/test/stage-path.test.ts +95 -0
  301. package/test/standard-coverage.test.ts +15 -3
  302. package/test/status-tally.test.ts +55 -0
  303. package/test/structure-revision-db.test.ts +4 -0
  304. package/test/tenant-registry-db.test.ts +1 -1
  305. package/test/time-range.test.ts +152 -0
  306. package/test/touched-items.test.ts +61 -0
  307. package/test/twin-event-keys.test.ts +10 -2
  308. package/test/twin-model-item-db.test.ts +2 -0
  309. package/test/warm-start-seam.test.ts +4 -0
  310. package/test/yield-loop.test.ts +57 -2
  311. package/tsconfig.shared.tsbuildinfo +1 -1
  312. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,110 @@
1
+ /*
2
+ * **저널을 쓰는 문은 하나다** — 그리고 멈출 때 남은 것을 흘린다 (ingest-scale §7.1, 2026-08-20).
3
+ *
4
+ * ── 무엇이 관측됐나 ─────────────────────────────────────────────────────────
5
+ * 데모 자극을 켠 트윈 23개가 도는 서버에서 프로세스가 CPU 81~112% 를 계속 쓰고, 아무 일도 하지 않는
6
+ * 질의가 `twinLoad` 8초 · `twinInstanceList` 14초였다. `sample` 프로파일은 `JSON.parse`·대형 GC·
7
+ * 스레드풀을 가리켰고, 계기는 **틱의 몫이 16%** 라고 말했다 — 틱이 아니었다.
8
+ *
9
+ * 코드가 이유였다: 기록 경로가 둘인데 **배치는 라이브만**이었다.
10
+ * · 라이브 — 200ms 마다 모아 벌크 저장
11
+ * · 시뮬 — 커널이 낸 **델타 하나마다** 행 하나 저장(그리고 그 안에서 구조 리비전을 물었다)
12
+ *
13
+ * 같은 파일이 **브로드캐스팅**에서 같은 함정을 이미 겪고 고쳤다(틱 하나가 35초). 쓰기만 단건으로 남아 있었다.
14
+ * ADR-0030 이 *"기록 경로가 둘인 것은 시뮬만 옆문으로 들어오기 때문"* 이라 예고한 그 자리다.
15
+ *
16
+ * ── 성능을 사려고 사실을 팔지 않는다 ────────────────────────────────────────
17
+ * 배치로 옮기면 주기 사이에 멈출 때 그 구간이 사라진다 — **라이브도 이미 그랬다**(최대 200ms). 그래서
18
+ * 정지 때 남은 것을 흘리는 자리를 함께 만들었다. 상태만 남기고 저널을 잃으면 「왜 그렇게 됐나」가 빈다.
19
+ *
20
+ * 이 시험은 소스로 규율을 지킨다(DB 없이 도는 스위트다). 정량화는 조용한 서버의 `journalRate` 가 답한다.
21
+ */
22
+ import { test } from 'node:test'
23
+ import assert from 'node:assert/strict'
24
+ import { readFileSync } from 'node:fs'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const engine = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8')
28
+
29
+ /**
30
+ * 메서드 본문만 — 다른 자리의 같은 낱말에 걸리지 않게.
31
+ *
32
+ * **다음 메서드 선언에서 끊는다.** 길이로 자르면 옆 메서드가 딸려 와, 「이 메서드가 몇 번 부르나」를
33
+ * 세는 단정이 조용히 틀린다(실제로 그렇게 2를 세었다).
34
+ */
35
+ function methodOf(src: string, name: string): string {
36
+ const at = src.indexOf(`${name}(`)
37
+ assert.ok(at >= 0, `${name} 가 없다`)
38
+ const rest = src.slice(at + name.length)
39
+ const next = rest.search(/\n (static|private static|async|[a-zA-Z]+\()/)
40
+ return rest.slice(0, next > 0 ? next : 3000)
41
+ }
42
+
43
+ test('시뮬이 델타마다 행을 쓰지 않는다 — 모아서 한 문으로 보낸다', () => {
44
+ const at = engine.indexOf("if (msg.kind === 'delta')")
45
+ const body = engine.slice(at, at + 2600)
46
+ assert.match(body, /pendingJournal/, '델타는 버퍼에 담긴다')
47
+ assert.equal(/this\.persist\(domainId, id, msg\)/.test(body), false, '단건 저장으로 되돌아갔다')
48
+ })
49
+
50
+ test('단건 저장 경로가 아예 없다 — 남겨 두면 다시 부를 자리가 된다', () => {
51
+ assert.equal(/static async persist\(domainId/.test(engine), false, '`persist` 가 되살아났다')
52
+ /* 쓰는 문은 둘이지만 **부르는 자리는 하나**다(`flushJournal`). */
53
+ /* 약속을 돌려준다 — 정지가 기다릴 수 있어야 한다(기다리지 않으면 웜스타트가 빈 저널을 읽는다). */
54
+ assert.match(engine, /private static flushJournal\(inst: InstanceRuntime\): Promise<void>/)
55
+ assert.match(engine, /await this\.flushJournal\(i\)/, '정지는 흘리기를 기다린다')
56
+ assert.match(engine, /static async persistBatch\(/)
57
+ assert.match(engine, /static async persistCarried\(/)
58
+ /* 넣기만 한다 — `save` 는 행마다 SELECT 를 한 번 더 한다(시험 로그에서 그 질의가 보였다). */
59
+ assert.match(engine, /private static async insertRows\(/)
60
+ assert.equal(/repo\.save\(rows, \{ chunk: 500 \}\)/.test(engine), false, '저널을 save 로 넣는 모양으로 되돌아갔다')
61
+ })
62
+
63
+ test('커널이 매긴 리비전을 호스트가 다시 번호 붙이지 않는다 — 시간여행이 그것을 딛는다', () => {
64
+ const flush = methodOf(engine, 'private static flushJournal')
65
+ /* 시뮬은 자기 리비전을 들고 오고(`{ event, revision }`), 라이브는 flush 에서 이어 붙인다. */
66
+ assert.match(flush, /typeof b\.revision === 'number'/, '들고 온 리비전을 가려낸다')
67
+ assert.match(flush, /inst\.revision = start \+ plain\.length/, '라이브만 이어 붙인다')
68
+ const carried = methodOf(engine, 'static async persistCarried')
69
+ assert.match(carried, /it\.revision/, '들고 온 리비전을 그대로 쓴다')
70
+ /* 구조 리비전은 배치에 **한 번**만 묻는다 — 행마다 묻던 것이 문제였다. */
71
+ assert.equal((carried.match(/structureRevOf/g) ?? []).length, 1)
72
+ })
73
+
74
+ test('멈출 때 남은 것을 흘린다 — 성능을 사려고 사실을 팔지 않는다', () => {
75
+ const stop = methodOf(engine, 'static async stop')
76
+ assert.match(stop, /this\.flushJournal\(i\)/, '정지 경로가 버퍼를 흘린다')
77
+ const flushAt = stop.indexOf('this.flushJournal(i)')
78
+ const dropAt = stop.indexOf('delete this.instances')
79
+ assert.ok(flushAt >= 0 && dropAt >= 0 && flushAt < dropAt, '런타임을 버리기 전에 흘려야 한다')
80
+ })
81
+
82
+ test('두 구동이 같은 계기를 든다 — 세지 않는 개선은 되돌려도 아무도 모른다', () => {
83
+ /*
84
+ * 계기(`inst.metrics`)가 `startLive` 에만 있었다. 그래서 도는 트윈 22개가 시뮬인 서버에서 「초당 몇 행을
85
+ * 쓰나」·「밀린 게 있나」를 물을 자리가 없었고, 모니터 목록에서도 시뮬이 조용히 빠졌다(계기가 null 이라
86
+ * 걸러졌다) — 부하가 거의 없는 것처럼 보였다.
87
+ */
88
+ assert.match(engine, /private static newMetrics\(\): TwinMetrics/, '계기 한 벌을 한 곳에서 만든다')
89
+ const sim = engine.slice(engine.indexOf("mode: 'sim'"), engine.indexOf("mode: 'sim'") + 1200)
90
+ assert.match(sim, /metrics: this\.newMetrics\(\)/, '시뮬도 계기를 든다')
91
+ /* 비율 갱신이 라이브만 보면 시뮬 칸은 영원히 0 이다. */
92
+ assert.equal(/const m = isLive \? inst\.metrics : undefined/.test(engine), false, '비율 갱신이 시뮬을 다시 잘라 냈다')
93
+ })
94
+
95
+ test('적은 것을 장부에 남긴다 — 계기는 지금만 답하고 재기동에 0 으로 돌아간다', () => {
96
+ const flush = methodOf(engine, 'private static flushJournal')
97
+ /* 실패한 배치를 「남았다」고 세지 않으려면 **쓴 뒤에** 적어야 한다. */
98
+ assert.match(flush, /this\.recordJournalWrite\(inst\.domainId, inst\.id, written\)/)
99
+ const thenAt = flush.indexOf('.then(')
100
+ const recAt = flush.indexOf('this.recordJournalWrite')
101
+ assert.ok(thenAt >= 0 && recAt > thenAt, '약속이 풀린 뒤에 적는다 — 터진 배치를 세지 않게')
102
+ })
103
+
104
+ test('구조 리비전은 「없다」도 기억한다 — 없는 트윈이 부를 때마다 질의하지 않게', () => {
105
+ const body = methodOf(engine, 'static async structureRevOf')
106
+ assert.match(body, /this\.structureRevCache\[key\] = latest \? latest\.rev : null/, '미스를 기억한다')
107
+ assert.equal(/if \(latest\) this\.structureRevCache/.test(body), false, '미스를 흘려버리는 모양으로 되돌아갔다')
108
+ /* 캐시가 `null` 을 담으므로 「모름」과 구별된다 — 형이 그 사실을 말해야 다음 사람이 0 으로 읽지 않는다. */
109
+ assert.match(engine, /structureRevCache: Record<string, number \| null>/)
110
+ })
@@ -0,0 +1,115 @@
1
+ /*
2
+ * **적은 것도 추이로 남는다** — 「저널이 얼마나 쓰이고 있나」를 나중에도 물을 수 있게 (2026-08-20).
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
5
+ * 계기(`journalRate`·`journaledTotal`)는 **지금**만 답한다. 프로세스를 다시 띄우면 0 에서 시작하므로
6
+ * 「어제 이 시각에도 이랬나」·「고친 뒤로 정말 줄었나」를 물을 자리가 없었다. 그런데 2026-08-20 에 고친
7
+ * 것이 바로 쓰기 경로(델타마다 한 행 → 창당 한 번)다. 세지 않는 개선은 되돌아가도 아무도 모른다.
8
+ *
9
+ * ── 왜 새 장부를 만들지 않았나 ──────────────────────────────────────────────
10
+ * 유입 창은 「닫힌 창만 최근」·「0 과 없음을 가른다」·「밀려난 것을 세어 드러낸다」를 이미 지키고, 닫힐 때
11
+ * 행으로 남는다. 같은 규율을 두 벌 만들면 한 벌만 고쳐진다. 그래서 그 창에 칸을 더했다.
12
+ *
13
+ * ── 이 시험이 지키는 것 ─────────────────────────────────────────────────────
14
+ * ① 유입이 한 번도 없어도 쓰기가 창을 넘긴다 — 시뮬 트윈이 추이에서 사라지지 않게.
15
+ * ② 행과 왕복을 **따로** 센다 — 행당 왕복이 1 로 되돌아가면 그것이 보인다.
16
+ * ③ 쓰기는 **「마지막 유입」을 갱신하지 않는다** — 섞으면 끊긴 피드가 옛 창으로 초록을 낸다.
17
+ * ④ 없는 것을 0 건 쓰기로 만들지 않는다.
18
+ * ⑤ 쓰기만 있는 장부의 시각이 1969년으로 뜨지 않는다.
19
+ */
20
+ import { test } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+
23
+ import {
24
+ INGEST_WINDOW_MS,
25
+ STALE_AFTER_MS,
26
+ ingestHealth,
27
+ newIngestLedger,
28
+ recordIngest,
29
+ recordJournalWrite
30
+ } from '../server/engine/ingest-health.ts'
31
+
32
+ const T0 = 1_700_000_000_000
33
+
34
+ /* ── ① 유입 없이도 추이가 생긴다 ──────────────────────────────────────────── */
35
+
36
+ test('받는 것 없이 적기만 하는 트윈도 추이에 남는다 — 시뮬이 조용히 빠지지 않게', () => {
37
+ const l = newIngestLedger()
38
+ const closed: any[] = []
39
+
40
+ recordJournalWrite(l, 40, T0, w => closed.push(w))
41
+ recordJournalWrite(l, 60, T0 + 1000, w => closed.push(w))
42
+ /* 창 길이를 넘긴 뒤의 쓰기가 앞 창을 닫는다. */
43
+ recordJournalWrite(l, 10, T0 + INGEST_WINDOW_MS + 1, w => closed.push(w))
44
+
45
+ assert.equal(closed.length, 1, '창이 닫혀 영속 훅까지 불린다')
46
+ assert.equal(closed[0].rows, 100)
47
+ assert.equal(closed[0].offered, 0, '유입은 0 이다 — 그것도 사실이다')
48
+
49
+ const view = ingestHealth(l, 'not-applicable', undefined, T0 + INGEST_WINDOW_MS + 2)
50
+ assert.equal(view.trend.length, 1, '유입 시각이 없다는 이유로 빈 화면을 내지 않는다')
51
+ assert.equal(view.trend[0].rows, 100)
52
+ assert.equal(view.total.rows, 110, '누적은 열린 창까지 센다')
53
+ })
54
+
55
+ /* ── ② 행과 왕복을 따로 센다 ──────────────────────────────────────────────── */
56
+
57
+ test('행과 왕복을 따로 센다 — 행당 왕복이 1 로 되돌아가면 그것이 보인다', () => {
58
+ const batched = newIngestLedger()
59
+ recordJournalWrite(batched, 500, T0)
60
+ recordJournalWrite(batched, 500, T0 + 200)
61
+ assert.equal(batched.rows, 1000)
62
+ assert.equal(batched.flushes, 2, '모아 보낸 것은 두 번이다')
63
+
64
+ /* 되돌아간 모양 — 같은 1,000행에 왕복 1,000번. 행 수만 세면 두 장부가 똑같이 보인다. */
65
+ const perDelta = newIngestLedger()
66
+ for (let i = 0; i < 1000; i++) recordJournalWrite(perDelta, 1, T0 + i)
67
+ assert.equal(perDelta.rows, batched.rows, '행 수는 같다 — 그래서 행만으로는 가려낼 수 없다')
68
+ assert.ok(perDelta.flushes > batched.flushes * 100, '왕복 수가 그 차이를 드러낸다')
69
+ })
70
+
71
+ /* ── ③ 축을 섞지 않는다 ───────────────────────────────────────────────────── */
72
+
73
+ test('쓰기가 「마지막 유입」을 갱신하지 않는다 — 끊긴 피드가 초록으로 보이지 않게', () => {
74
+ const l = newIngestLedger()
75
+ /* 건강한 유입이 한동안 있었고, 그 뒤 피드가 끊겼다. */
76
+ recordIngest(l, 100, [], T0)
77
+ recordIngest(l, 100, [], T0 + INGEST_WINDOW_MS + 1) /* 앞 창을 닫아 「최근」을 만든다 */
78
+
79
+ const muchLater = T0 + INGEST_WINDOW_MS + STALE_AFTER_MS + 60_000
80
+ /* 그 사이에도 트윈은 틱이 만든 델타를 계속 적는다 — 라이브에서 실제로 그렇다. */
81
+ for (let t = T0 + 60_000; t < muchLater; t += 60_000) recordJournalWrite(l, 5, t)
82
+
83
+ const view = ingestHealth(l, 'attached', undefined, muchLater)
84
+ /*
85
+ * 판정이 초록이 아니어야 한다. 어떤 말로 부르는지는 창의 모양에 달렸다(마지막 닫힌 창이 유입 0 이면
86
+ * 「유입 없음」, 창이 아직 안 닫혔으면 「끊긴 피드」) — 둘 다 「받고 있지 않다」다. 못 박는 것은 **적고
87
+ * 있다는 사실이 「받고 있다」로 번역되지 않는다**는 것이다.
88
+ */
89
+ assert.notEqual(view.verdict, 'healthy', '적고 있다는 사실이 「받고 있다」로 번역되지 않는다')
90
+ assert.equal(view.lastAt, new Date(T0 + INGEST_WINDOW_MS + 1).toISOString(), '마지막 유입 시각이 쓰기로 밀리지 않는다')
91
+ assert.equal(view.lastWriteAt, new Date(muchLater - 60_000).toISOString(), '적은 시각은 따로 낸다')
92
+ })
93
+
94
+ /* ── ④⑤ 없는 것과 0 ──────────────────────────────────────────────────────── */
95
+
96
+ test('없는 것을 0 건 쓰기로 만들지 않는다', () => {
97
+ const l = newIngestLedger()
98
+ for (const bad of [0, -3, NaN, Infinity]) recordJournalWrite(l, bad as number, T0)
99
+ assert.equal(l.flushes, 0, '아무 일도 없었으면 왕복도 없다')
100
+ assert.equal(l.rows, 0)
101
+ assert.equal(l.lastWriteAtMs, -1, '적지 않았으면 적은 시각도 없다')
102
+ assert.equal(ingestHealth(l, 'not-applicable', undefined, T0).lastWriteAt, null)
103
+ })
104
+
105
+ test('쓰기만 있는 장부의 시각이 1969년으로 뜨지 않는다 — `-1` 을 시각으로 읽지 않는다', () => {
106
+ const l = newIngestLedger()
107
+ recordJournalWrite(l, 7, T0)
108
+ recordJournalWrite(l, 7, T0 + INGEST_WINDOW_MS + 1)
109
+
110
+ const view = ingestHealth(l, 'not-applicable', undefined, T0 + INGEST_WINDOW_MS + 2)
111
+ assert.equal(view.lastAt, null, '받은 적이 없으면 유입 시각은 **없다**(0 이 아니다)')
112
+ for (const w of [...view.trend, view.recent!]) {
113
+ assert.ok(new Date(w.to).getUTCFullYear() >= 2023, `창 끝 시각이 ${w.to} 로 떴다`)
114
+ }
115
+ })
@@ -31,7 +31,7 @@ const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutati
31
31
  /*
32
32
  * 종류 폴백은 **한 패키지에 갇혀 있지 않다.** 처음에 twin-engine 두 자리만 고쳤더니, 라이브 미러를
33
33
  * 띄우는 호출자 넷(operato-twin 의 벤치·부하시험·프리셋 프로비저닝, headless 의 reference-live)이
34
- * `det.kind ?? 'wms'` 로 **엔진이 던지기 전에 먼저 이기고** 있었다. 커넥터 spec 도 마찬가지였다.
34
+ * `det.kind ?? 'wms'` 로 **엔진이 오류를 내기 전에 먼저 값을 정하고** 있었다. 커넥터 spec 도 마찬가지였다.
35
35
  * 그래서 두 패키지의 서버 코드를 함께 훑는다.
36
36
  */
37
37
  const ROOTS = [new URL('../server', import.meta.url).pathname, new URL('../../operato-twin/server', import.meta.url).pathname]
@@ -50,7 +50,7 @@ function codeOf(path: string): string[] {
50
50
 
51
51
  test('커널 폴백이 없다 — 모르는 종류는 창고로 떨어지지 않는다', () => {
52
52
  const offenders = codeOf(ENGINE).filter(l => /\?\?\s*WmsKernel|\|\|\s*WmsKernel/.test(l))
53
- assert.deepEqual(offenders, [], '커널 선택에 폴백이 다시 생겼다 — kernelFor 던지게 할 것')
53
+ assert.deepEqual(offenders, [], '커널 선택에 폴백이 다시 생겼다 — kernelFor 오류를 내게 할 것')
54
54
  })
55
55
 
56
56
  test('종류→커널 해소는 kernelFor 한 곳만 한다', () => {
@@ -59,10 +59,10 @@ test('종류→커널 해소는 kernelFor 한 곳만 한다', () => {
59
59
  assert.deepEqual(indexing, [], 'KERNELS 를 밖에서 색인하고 있다 — kernelFor(kind) 를 쓸 것')
60
60
  })
61
61
 
62
- test('kernelFor 는 모르는 종류에 던진다 — 조용히 고르지 않는다', () => {
62
+ test('kernelFor 는 모르는 종류에 오류를 낸다 — 조용히 고르지 않는다', () => {
63
63
  const src = readFileSync(ENGINE, 'utf-8')
64
64
  const fn = src.slice(src.indexOf('function kernelFor'))
65
- assert.match(fn.slice(0, 300), /throw new Error/, 'kernelFor 가 던지지 않으면 이 계약이 무너진다')
65
+ assert.match(fn.slice(0, 300), /throw new Error/, 'kernelFor 가 오류를 내지 않으면 이 계약이 무너진다')
66
66
  })
67
67
 
68
68
  test("board 를 주는 기동은 kind 를 요구한다 — `kind ?? 'wms'` 로 메우지 않는다", () => {
@@ -119,9 +119,51 @@ test('자원 점유: 작업에 붙어 있던 시간의 합과 창 대비 비율'
119
119
  assert.equal(Math.round(r.utilization[0].ratio * 100), 50, '1시간 창에서 30분 = 50%')
120
120
  })
121
121
 
122
- test('오더 완료: 도메인 어휘를 넓게 받는다(completed/fulfilled/done)', () => {
123
- const r = foldKpi([order('o1', 'completed', 10), order('o2', 'fulfilled', 20), order('o3', 'in-progress', 30)], WINDOW)
124
- assert.equal(r.throughput.orders, 2)
122
+ /*
123
+ * ── 오더 완료: **동의어를 받지 않는다**(2026-08-23 바뀐 규칙) ─────────────
124
+ *
125
+ * 이 시험은 예전에 「도메인 어휘를 넓게 받는다(completed/fulfilled/done)」였다 — 그것이 **동의어 목록**
126
+ * 이었고, 그 방식이 낸 대가가 둘이었다.
127
+ *
128
+ * ① 원본의 낱말이 목록에 없으면 완료가 0 이 된다(첫 실 시스템의 `FINISHED` 가 그랬다).
129
+ * ② **시뮬 트윈에서는 언제나 0 이었다** — 시뮬은 `fulfilled` 만 올리고 `status` 는 바꾸지 않는다.
130
+ *
131
+ * 판정은 이제 커널이 한다(`isOrderTerminal`): 1차 근거는 **양**, 낱말은 닫힌 둘(`completed`·`cancelled`).
132
+ * 표준이 그 축을 갈라 두었다 — `RequestState` 는 표준도 열거하지 않고, 「됐나」는 실적에서 읽는다.
133
+ */
134
+ test('오더 완료: 닫힌 종결 낱말만 받는다 — 동의어는 추론하지 않는다', () => {
135
+ const r = foldKpi(
136
+ [order('o1', 'completed', 10), order('o2', 'cancelled', 20), order('o3', 'in-progress', 30)],
137
+ WINDOW
138
+ )
139
+ assert.equal(r.throughput.orders, 2, 'completed · cancelled 둘')
140
+ const r2 = foldKpi([order('o4', 'fulfilled', 10), order('o5', 'done', 20), order('o6', 'FINISHED', 30)], WINDOW)
141
+ assert.equal(r2.throughput.orders, 0, '동의어로 보이는 낱말은 세지 않는다 — 커넥터가 옮길 일이다')
142
+ assert.deepEqual(r2.orderStatusNotCounted, { fulfilled: 1, done: 1, finished: 1 }, '대신 그 사실을 말한다')
143
+ })
144
+
145
+ test('오더 완료: 양이 답한다 — 낱말이 진행 중이라 말해도', () => {
146
+ /*
147
+ * 시뮬을 되살리는 자리다. 시뮬 오더는 이행돼도 상태가 `created` 로 남으므로, 낱말로 묻는 판정은
148
+ * 그 완료를 한 건도 세지 못했다.
149
+ */
150
+ const qty = (orderId: string, status: string, sec: number, requested: number, fulfilled: number): KpiEvent => ({
151
+ eventType: 'order.status',
152
+ eventTime: at(sec),
153
+ payload: { data: { orderId, status, requested, fulfilled } }
154
+ })
155
+ const r = foldKpi([qty('o1', 'created', 10, 10, 10), qty('o2', 'created', 20, 10, 9)], WINDOW)
156
+ assert.equal(r.throughput.orders, 1, '10 중 10 은 끝났고 10 중 9 는 아니다')
157
+ })
158
+
159
+ test('오더 완료: 같은 오더를 한 번만 센다 — 폴링 원본이 다시 보내도', () => {
160
+ /*
161
+ * 폴링하는 원본은 같은 오더의 종결을 여러 주기에 걸쳐 다시 보낼 수 있다(결함이 아니라 폴링의 성질).
162
+ * 사건마다 세면 처리량이 조용히 부풀려진다 — 작업 쪽은 커널의 짝맞춤이 이미 그 규율을 갖고 있었고
163
+ * 오더만 없었다.
164
+ */
165
+ const r = foldKpi([order('o1', 'completed', 10), order('o1', 'completed', 20), order('o2', 'completed', 30)], WINDOW)
166
+ assert.equal(r.throughput.orders, 2, '오더 둘 — 사건은 셋이다')
125
167
  })
126
168
 
127
169
  test('저널이 비면 0 이 아니라 "본 이벤트 없음" 으로 드러난다', () => {
@@ -1087,3 +1129,48 @@ test('피크 구간에 표본이 없던 분기는 피크 값을 만들지 않는
1087
1129
  assert.equal(byKey.get('feeder-a')?.peakKW, 300)
1088
1130
  assert.equal(byKey.get('feeder-b')?.peakKW, undefined, '그 순간 값을 모르면 만들지 않는다')
1089
1131
  })
1132
+
1133
+ /*
1134
+ * ── 완료로 읽지 못한 오더 상태 낱말을 **센다** (2026-08-23) ──────────────────
1135
+ *
1136
+ * 오더의 상태 어휘는 계약이 도메인에 넘긴 것이라(`picking`·`shipped`·`FINISHED`…) 이 폴드는 「완료로
1137
+ * 읽히는 표현」을 동의어 목록으로 받는다. 그 대가는 **목록에 없는 낱말이 조용히 0 으로 세어진다**는 것이다.
1138
+ *
1139
+ * 실제로 그랬다: `finished` 가 빠져 있어 첫 실 시스템의 완료 오더가 전부 0 이었고, 사람이 화면을 보고
1140
+ * 의심해서야 드러났다. 목록을 넓히는 것으로는 다음 원본을 막지 못한다 — 그래서 **못 읽은 것을 센다.**
1141
+ */
1142
+ test('완료로 센 낱말은 여기 오지 않는다 — 이 값은 세지 않은 것의 인구조사다', () => {
1143
+ const r = foldKpi(
1144
+ [order('o-1', 'completed', 10), order('o-2', 'CANCELLED', 20), order('o-3', 'cancelled', 30)],
1145
+ WINDOW
1146
+ )
1147
+ assert.equal(r.throughput.orders, 3, '대소문자를 가리지 않는다')
1148
+ assert.deepEqual(r.orderStatusNotCounted, {}, '완료로 센 낱말은 여기 오지 않는다')
1149
+ })
1150
+
1151
+ test('세지 않은 낱말이 건수와 함께 남는다 — 완료 0 의 원인이 곧 보이게', () => {
1152
+ const r = foldKpi(
1153
+ [order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 20), order('o-3', 'picking', 30)],
1154
+ WINDOW
1155
+ )
1156
+ assert.equal(r.throughput.orders, 0, '목록에 없는 낱말을 완료로 세지 않는다 — 추론하지 않는 것이 옳다')
1157
+ assert.deepEqual(
1158
+ r.orderStatusNotCounted,
1159
+ { closed: 2, picking: 1 },
1160
+ '무엇을 몇 건 세지 않았는지 말한다 — picking 이 함께 오는 것은 정상이다(완료가 아니다)'
1161
+ )
1162
+ })
1163
+
1164
+ test('창 밖의 낱말은 세지 않는다 — 이 값도 창의 사실이다', () => {
1165
+ const r = foldKpi([order('o-1', 'CLOSED', 10), order('o-2', 'CLOSED', 7200)], WINDOW)
1166
+ assert.deepEqual(r.orderStatusNotCounted, { closed: 1 }, '창 안의 것만 센다')
1167
+ })
1168
+
1169
+ test('상태를 말하지 않은 사건은 여기 오지 않는다 — 그것은 다른 결함이다', () => {
1170
+ /*
1171
+ * 빈 낱말을 여기 섞으면 이 값의 뜻이 흐려진다 — 상태가 없는 사건은 **다른 결함**이고(유입 문이 상태를
1172
+ * 필수로 받는다) 그 진단은 그 문의 몫이다.
1173
+ */
1174
+ const r = foldKpi([{ eventType: 'order.status', eventTime: at(10), payload: { data: { orderId: 'o-1' } } }], WINDOW)
1175
+ assert.deepEqual(r.orderStatusNotCounted, {})
1176
+ })
@@ -1,10 +1,10 @@
1
1
  /*
2
- * 구간 성과 질의 비용 측정 — **방송이냐 질의냐를 숫자로 정한다.**
2
+ * 구간 성과 질의 비용 측정 — **브로드캐스팅이냐 질의냐를 숫자로 정한다.**
3
3
  *
4
4
  * ── 왜 재는가 (2026-08-06) ─────────────────────────────────────────────────
5
5
  * 성과 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려 한다. 두 방법이 있다.
6
6
  *
7
- * · 방송 — 서버가 주기마다 전 단계를 계산해 밀어 준다. 자리가 트윈당 최대 200개고 지표가 6개라
7
+ * · 브로드캐스팅 — 서버가 주기마다 전 단계를 계산해 밀어 준다. 자리가 트윈당 최대 200개고 지표가 6개라
8
8
  * 태그가 1,200개가 된다. **모델에 카드를 하나도 안 놓아도 계산은 돈다.**
9
9
  * · 질의 — 카드가 필요할 때 묻는다. **모델에 놓인 카드 수만큼만** 든다. 시각(`toTime`)도 물을 수 있어
10
10
  * 타임라인과 맞고, 공간 단위 합산(여러 트윈을 한 번에 접기)은 질의에만 있다.
@@ -53,8 +53,24 @@ const master = (): any => ({
53
53
  productionSpec: {
54
54
  companyPrefix: CP,
55
55
  binding: { steel: '077101', frame: '078101' },
56
+ /*
57
+ * 작업지시의 문서 식별자를 **이 현장이 선언한다** — 커널은 문서 타입을 정하지 않습니다(0.7.43).
58
+ * GDTI 는 「회사 프리픽스 + 문서 타입 + 일련번호」이고, 그 타입은 프리픽스를 배정받은 회사가
59
+ * 자기 번호 용량에서 배정합니다. 그러니 픽스처도 그 선언을 해야 합니다.
60
+ */
61
+ identity: { documentTypes: { workorder: '403' } },
56
62
  definition: {
57
- materials: [{ id: 'steel' }, { id: 'frame' }],
63
+ /* 자재는 **자기 보관 자리를 선언한다** 커널이 자재가 놓이는 자리를 지어내지 않습니다(0.7.43). */
64
+ materials: [
65
+ { key: 'steel', label: '강재', locationType: 'raw-store' },
66
+ { key: 'frame', label: '프레임', locationType: 'fg-store' }
67
+ ],
68
+ locationTypes: [
69
+ { key: 'raw-store', label: '자재 보관' },
70
+ { key: 'cut-station', label: '절단' },
71
+ { key: 'weld-station', label: '용접' },
72
+ { key: 'fg-store', label: '완제품 보관' }
73
+ ],
58
74
  routes: [{ key: 'frame-route', steps: ['cut', 'weld'] }],
59
75
  recipes: [{ key: 'frame-recipe', label: 'f', route: 'frame-route', inputs: [{ material: 'steel', qty: 2 }], outputs: [{ material: 'frame', qty: 1 }] }],
60
76
  operations: [
@@ -0,0 +1,87 @@
1
+ /*
2
+ * ★ 읽기 커서는 **부르는 쪽이 자리를 가리켜야** 이어진다 — 커넥터는 그 이름을 알 수 없다.
3
+ *
4
+ * ── 무엇이 조용히 끊겨 있었나 (2026-08-23) ──────────────────────────────────
5
+ * 커서를 저장할 컬럼과 계약을 만들고 배선까지 했는데, **한 번도 저장되지 않았다.** 저장할 자리를
6
+ * `cfg`·`site` 에서 찾으려 했기 때문이다.
7
+ *
8
+ * cfg 사람이 화면에서 입력한 접속 설정 — endpoint · token · subdomain · liveLookbackDays
9
+ * site 커넥터가 원본에서 발견한 것 — { siteId, name }
10
+ *
11
+ * **둘 다 그 값을 가질 수 없다.** 레퍼런스의 이름(`TwinReference.source`)은 **호스트가 붙이는 것**이고
12
+ * 원본은 그것을 모른다. 커넥터가 채울 수 없는 값을 커넥터에서 읽으려 한 것이 잘못이었다.
13
+ *
14
+ * 그리고 그 실패는 **로그로는 말했다** — 「this feed does not say which reference it reads from」.
15
+ * 그 한 줄로 다른 레인이 바로 짚었다(조용히 넘기지 않은 것이 값을 했다). 그러나 로그는 사람이 볼 때만
16
+ * 값이 있다. 배선이 다시 끊기면 이 시험이 먼저 말한다.
17
+ *
18
+ * ── 왜 「늘었나」로 확인하면 안 되나 ────────────────────────────────────────
19
+ * 재기동 뒤 수가 늘어도 그것은 커서가 이어진 증거가 아니다 — 실제로 같은 판에 다른 변경(공정별 전이)이
20
+ * 들어가 작업 수가 배로 늘었고, 그 사이 커서는 여전히 매번 새로 만들어졌다. 확인할 것은 **배선**이다.
21
+ */
22
+ import { test } from 'node:test'
23
+ import assert from 'node:assert/strict'
24
+ import { readFileSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+
27
+ const DIR = join(import.meta.dirname, '..', 'server', 'service', 'reference')
28
+ const LIVE = readFileSync(join(DIR, 'reference-live.ts'), 'utf8')
29
+ const RESOLVER = readFileSync(join(DIR, 'reference-resolver.ts'), 'utf8')
30
+
31
+ /** 주석을 걷어낸 코드만 — 「쓰지 않는다」를 적어 둔 근거가 가드를 뒤집지 않게. */
32
+ function codeOf(src: string): string {
33
+ return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
34
+ }
35
+
36
+ test('커서를 저장할 자리는 부르는 쪽이 가리킨다 — cfg·site 에서 짐작하지 않는다', () => {
37
+ const code = codeOf(LIVE)
38
+ /* 커넥터가 채울 수 없는 값을 커넥터의 인자에서 읽지 않는다. */
39
+ assert.doesNotMatch(code, /cfg as any\)\?\.source/, 'cfg 에서 레퍼런스 이름을 찾지 않는다')
40
+ assert.doesNotMatch(code, /site as any\)\?\.source/, 'site 에서 레퍼런스 이름을 찾지 않는다')
41
+ /* 대신 인자로 받는다. */
42
+ assert.match(code, /loadLiveCursor\(domainId, instanceId, source\)/, '받은 이름으로 커서를 찾는다')
43
+ /* 가드가 스스로를 증명한다 — 주석을 걷어낸 코드가 비어 버리지 않았다. */
44
+ assert.match(code, /startReferenceLiveFeed/, '대상 함수가 남아 있다')
45
+ })
46
+
47
+ test('모든 호출처가 그 이름을 넘긴다 — 한 곳만 빠져도 그 경로는 조용히 창이 미끄러진다', () => {
48
+ /*
49
+ * 호출처가 둘이고 **둘 다** `TwinReference` 를 들고 있다(부팅 재개 · 인제스트 직후 기동).
50
+ * 하나만 넘기면 그 경로로 세워진 트윈만 커서를 잃고, 그 차이는 오류 없이 조용하다.
51
+ */
52
+ const calls = [...codeOf(LIVE).matchAll(/startReferenceLiveFeed\(([^)]*)\)/g)].map(m => m[1])
53
+ const resolverCalls = [...codeOf(RESOLVER).matchAll(/startReferenceLiveFeed\([\s\S]{0,200}?\)/g)].map(m => m[0])
54
+ const all = [...calls.filter(a => !a.includes('domainId: string')), ...resolverCalls]
55
+ assert.ok(all.length >= 2, `호출처를 찾지 못했다 (${all.length}) — 이 가드가 아무것도 지키지 않는다`)
56
+ for (const call of all) {
57
+ assert.match(call, /ref\.source/, `이 호출처가 레퍼런스 이름을 넘기지 않는다: ${call.replace(/\s+/g, ' ')}`)
58
+ }
59
+ })
60
+
61
+ test('이름이 없으면 조용히 돌지 않는다 — 말한다', () => {
62
+ /*
63
+ * 커서 없이 도는 것은 「창이 매번 미끄러진다」는 뜻이고, 그 손실은 오류 없이 사라진다. 그래서 그
64
+ * 사실을 로그로 말한다 — 실제로 그 한 줄이 이 결함을 찾아냈다.
65
+ */
66
+ const code = codeOf(LIVE)
67
+ assert.match(code, /if \(!source\)[\s\S]{0,200}twinWarn/, '이름이 없으면 경고한다')
68
+ assert.match(code, /did not say which reference/, '무엇이 없어서 못 이었는지 말한다')
69
+ /* 행을 못 찾은 경우도 따로 말한다 — 이름은 왔는데 그 레퍼런스가 없는 것은 다른 사실이다. */
70
+ assert.match(code, /no reference row for source/, '레퍼런스 행이 없는 경우를 가려 말한다')
71
+ })
72
+
73
+ test('커서는 레퍼런스의 것이다 — 인스턴스마다 두지 않는다', () => {
74
+ /*
75
+ * 한 레퍼런스가 여러 인스턴스를 낳고(`scopeSpec.produced`) 원본을 읽는 것은 한 번이다.
76
+ * 인스턴스마다 커서를 두면 같은 표를 여러 번 읽는다.
77
+ */
78
+ const code = codeOf(LIVE)
79
+ assert.match(code, /getRepository\(TwinReference\)/, '레퍼런스 행에 저장한다')
80
+ assert.match(code, /liveCursor: next/, '어댑터가 준 모양을 그대로 적는다')
81
+ assert.doesNotMatch(code, /liveCursor[\s\S]{0,40}instanceId/, '인스턴스로 커서를 가르지 않는다')
82
+ })
83
+
84
+ test('저장 실패를 삼키지 않는다', () => {
85
+ /* 조용히 실패하면 다음 재기동에서 창이 다시 미끄러지고, 이 컬럼을 만든 이유가 사라진다. */
86
+ assert.match(codeOf(LIVE), /live cursor not saved/, '저장 실패를 말한다')
87
+ })
@@ -11,7 +11,15 @@
11
11
  import { test } from 'node:test'
12
12
  import assert from 'node:assert/strict'
13
13
 
14
- import { registerLiveFeedProbe, liveFeedAttached, liveFeedStateOf } from '../server/engine/live-feed-registry.ts'
14
+ /*
15
+ * **빌드 산출물을 부른다** — 이 모듈은 로그 문(`engine/log.js`)을 값으로 참조하고, 타입만 지우고 소스를
16
+ * 실행하는 방식은 그 `.js` 경로를 `.ts` 로 바꿔 주지 않는다. 소스를 부르면 「모듈을 찾을 수 없다」로 선다.
17
+ * **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌린다.**
18
+ */
19
+ import { createRequire } from 'node:module'
20
+
21
+ const req = createRequire(import.meta.url)
22
+ const { registerLiveFeedProbe, liveFeedAttached, liveFeedStateOf } = req('../dist-server/engine/live-feed-registry.js')
15
23
 
16
24
  /* 두 계층의 장부를 흉내낸다 — 커넥터 기반과 프리셋 기반. */
17
25
  const connectorFeeds = new Set<string>()
@@ -57,26 +57,26 @@ test('식별자가 아니라 **객체 동일성**으로 가른다 — 어댑터
57
57
  assert.equal(/applying\.has\(e\.eventId\)/.test(start), false, 'id 비교로 되돌아갔다')
58
58
  })
59
59
 
60
- test('커널이 낸 사실도 방송을 예약한다 — 피크·마감은 상태를 바꾼다', () => {
60
+ test('커널이 낸 사실도 브로드캐스팅을 예약한다 — 피크·마감은 상태를 바꾼다', () => {
61
61
  const body = methodOf('startLive')
62
- assert.match(body, /inst\.dirty = true/, '상태가 바뀌었으므로 다음 주기에 방송해야 한다')
63
- assert.match(body, /ensureBroadcastCoalescer\(\)/, '방송은 주기로 합친다(사건마다 방송하면 폭발한다)')
62
+ assert.match(body, /inst\.dirty = true/, '상태가 바뀌었으므로 다음 주기에 브로드캐스팅해야 한다')
63
+ assert.match(body, /ensureBroadcastCoalescer\(\)/, '브로드캐스팅은 주기로 합친다(사건마다 브로드캐스팅하면 폭발한다)')
64
64
  })
65
65
 
66
66
  /*
67
- * ── 구조 전환도 방송한다 (2026-08-18) ───────────────────────────────────────
67
+ * ── 구조 전환도 브로드캐스팅한다 (2026-08-18) ───────────────────────────────────────
68
68
  * 화면이 보는 것 상당수가 구조에서 파생된다(계약 대비 판정·주목 신호·자리 색). 구조 전환이 조용히
69
69
  * 끝나면 지도 레일은 다음 계측 표본이 올 때까지 옛 화면을 들고 있고, 헤더 배지는 폴링으로 먼저 알아
70
70
  * 「배지엔 있고 목록엔 없는」 어긋난 화면이 된다(실화면에서 봤다).
71
71
  */
72
- test('구조 전환은 상태 방송을 예약한다 — 다음 표본까지 화면이 옛것을 들고 있지 않게', () => {
72
+ test('구조 전환은 상태 브로드캐스팅을 예약한다 — 다음 표본까지 화면이 옛것을 들고 있지 않게', () => {
73
73
  const body = methodOf('adoptStructure')
74
74
  assert.match(body, /inst\.dirty = true/, '구조가 바뀐 순간이 상태가 바뀐 순간이다')
75
75
  assert.match(body, /ensureBroadcastCoalescer\(\)/, '새 경로가 아니라 있는 병합 규율에 얹는다')
76
- /* 커널 전환보다 먼저 방송하면 구독자가 옛 스냅샷을 되물어 간다 — 순서가 규율이다. */
76
+ /* 커널 전환보다 먼저 브로드캐스팅하면 구독자가 옛 스냅샷을 되물어 간다 — 순서가 규율이다. */
77
77
  assert.ok(
78
78
  body.indexOf('inst.kernel.adoptStructure(model)') < body.indexOf('inst.dirty = true'),
79
- '커널을 갈고 나서 방송한다'
79
+ '커널을 갈고 나서 브로드캐스팅한다'
80
80
  )
81
81
  })
82
82
 
@@ -22,6 +22,10 @@ const master = () => ({
22
22
  source: 'mirror',
23
23
  system: 'wms' as const,
24
24
  siteName: 'M',
25
+ /* 이 픽스처가 식별자를 만드는 이름공간 — **픽스처마다 다르게** 둔다(같은 접두 아래에서 서로 다른
26
+ 시험의 번호가 부딪치면 표준의 유일성 요구를 깬다, CBV 2.0 §8.2.4). 커널은 선언 없이 팔레트·문서를
27
+ 만들지 않는다(0.7.45 에서 날조 프리픽스를 지웠다). */
28
+ identity: { namespaces: ['https://twin.test/live-mirror-parity'] },
25
29
  space: { width: 90, depth: 40, unit: 'm' },
26
30
  locations: [
27
31
  { id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' },
@@ -99,6 +103,8 @@ test('★ 라이브 미러: 핵심 관측 상태(점유·용량·위치·아이
99
103
  function runSimRich() {
100
104
  const m = {
101
105
  source: 'rich', system: 'wms' as const, siteName: 'R',
106
+ /* 같은 파일이라도 픽스처가 다르면 이름공간도 다르다 — 두 픽스처의 번호가 한 공간에서 겹치지 않게. */
107
+ identity: { namespaces: ['https://twin.test/live-mirror-parity/rich'] },
102
108
  space: { width: 90, depth: 40, unit: 'm' },
103
109
  locations: [
104
110
  { id: 'a', type: 'warehouse', role: 'area' as const, name: '창고' },
@@ -11,21 +11,7 @@
11
11
  import { test } from 'node:test'
12
12
  import assert from 'node:assert/strict'
13
13
 
14
- import {
15
- fleetLoad,
16
- newCpuSampler,
17
- sampleCpu,
18
- judgeCycle,
19
- loadSummary,
20
- newLoadMeter,
21
- recordFork,
22
- recordPhase,
23
- rollWindow,
24
- slowTickMessage,
25
- TICK_BUDGET_RATIO,
26
- WARN_COOLDOWN_MS,
27
- WINDOW_MS
28
- } from '../server/engine/load-meter.ts'
14
+ import { LOAD_PHASES, fleetLoad, newCpuSampler, sampleCpu, judgeCycle, loadSummary, newLoadMeter, recordFork, recordPhase, rollWindow, slowTickMessage, TICK_BUDGET_RATIO, WARN_COOLDOWN_MS, WINDOW_MS } from '../server/engine/load-meter.ts'
29
15
 
30
16
  const TICK = 1000
31
17
 
@@ -312,3 +298,31 @@ test('시계가 뒤로 가거나 구간이 0 이면 비율을 지어내지 않
312
298
  assert.equal(sampleCpu(s, 1_200_000, 1000), null, '구간 0')
313
299
  assert.equal(sampleCpu(newCpuSampler(), 5, 5), null)
314
300
  })
301
+
302
+ /*
303
+ * ── 부팅 구간도 재는 이름을 갖는다 (2026-08-22) ─────────────────────────────
304
+ * 실측으로 부팅이 620~717초였고 트윈 하나를 세우는 데 15~93초였는데, **어느 작업이 그 시간을 쓰는지
305
+ * 답할 이름이 없었다** — 재는 이름이 도는 중의 작업뿐이었다. 그래서 짐작으로 원인을 적을 수밖에
306
+ * 없었고, 그것은 이 프로젝트가 금하는 일이다.
307
+ */
308
+ test('부팅 구간을 재는 이름이 있다 — 웜스타트와 자극 시작', () => {
309
+ assert.ok(LOAD_PHASES.includes('warmStart' as any), '상태 심기를 재는 이름이 있어야 한다')
310
+ assert.ok(LOAD_PHASES.includes('stimulus' as any), '자극 시작을 재는 이름이 있어야 한다')
311
+
312
+ /* 계기가 그 값을 실제로 들고 답한다 — 이름만 있고 담기지 않으면 화면이 빈칸을 보인다. */
313
+ const m = newLoadMeter()
314
+ recordPhase(m, 'warmStart', 93_000, 1_000)
315
+ recordPhase(m, 'stimulus', 72_000, 1_000)
316
+ assert.equal(m.phases.warmStart?.maxMs, 93_000)
317
+ assert.equal(m.phases.stimulus?.maxMs, 72_000)
318
+ })
319
+
320
+ test('부팅 구간은 틱 예산으로 판정하지 않는다 — 한 번뿐인 일이다', () => {
321
+ /*
322
+ * 웜스타트는 트윈을 세울 때 한 번이고 몇십 초가 정상일 수 있다. 그것을 틱 예산(간격의 절반)으로
323
+ * 재면 부팅마다 「느린 주기」가 쏟아진다 — 거짓 경보는 참 경보를 죽인다. 판정은 틱만 받는다.
324
+ */
325
+ const m = newLoadMeter()
326
+ recordPhase(m, 'warmStart', 93_000, 1_000)
327
+ assert.equal(m.overBudget, 0, '기록만으로는 판정하지 않는다')
328
+ })
@@ -426,7 +426,7 @@ test('현장 겹이 원천 값을 덮으면 그 원천 값을 기억한다 —
426
426
  * 모델에 설비 `load-1` 이 두 번 있던 것이다. 배선이 사라지지 않게 소스 모양으로 지킨다(이 함수는 DB 를
427
427
  * 물고 있어 단위 시험이 불러올 수 없다).
428
428
  */
429
- test('구조 투영이 중복 id 를 이름으로 말한다 — 제약 위반 문구를 사용자에게 던지지 않는다', () => {
429
+ test('구조 투영이 중복 id 를 이름으로 말한다 — 제약 위반 문구를 사용자에게 보이지 않는다', () => {
430
430
  const src = readFileSync(fileURLToPath(new URL('../server/service/twin-model/project-structure.ts', import.meta.url)), 'utf-8')
431
431
  assert.match(src, /has two \$\{what\} with id/, '무엇이 겹쳤는지 이름으로 말한다')
432
432
  assert.match(src, /ids must be unique within a twin/, '규칙도 함께 말한다')