@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
@@ -14,11 +14,12 @@
14
14
  * 상수를 앱에서 재현하지 않고(무방언), 라이브 트윈에서도 자연스럽다(마지막 기록 ≈ 지금).
15
15
  * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.
16
16
  */
17
+ import { twinWarn } from './log.js'
17
18
  import { Between, In } from 'typeorm'
18
19
 
19
20
  import { hierarchyOf, levelOfLocationType, electricalUpstreamOf, EMS_LOCATION_TYPES, EMS_PROPERTY, type TariffDeclaration } from '@operato/twin-kernel'
20
21
 
21
- import { getRepository } from '@things-factory/shell'
22
+ import { getReadRepository, getRepository } from '@things-factory/shell'
22
23
 
23
24
  import { TwinEvent } from '../service/twin-event/twin-event.js'
24
25
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
@@ -283,7 +284,7 @@ async function locationAreaMap(
283
284
  for (const row of rows) {
284
285
  const locations = (((row.model as any)?.locations ?? []) as any[]).filter(n => n?.id)
285
286
  if (!locations.length) continue
286
- /* 순환은 hierarchyOf 가 던진다 — 한 트윈의 잘못된 계층이 다른 트윈의 집계까지 죽이지 않게 여기서 가둔다. */
287
+ /* 순환은 hierarchyOf 가 오류를 낸다 — 한 트윈의 잘못된 계층이 다른 트윈의 집계까지 죽이지 않게 여기서 가둔다. */
287
288
  let h
288
289
  try {
289
290
  /*
@@ -293,7 +294,7 @@ async function locationAreaMap(
293
294
  */
294
295
  h = hierarchyOf({ locations }, t => levelOfLocationType(t, row.kind))
295
296
  } catch (e: any) {
296
- console.warn(`[twin-kpi] "${row.instanceId}": location hierarchy is broken — area rollup skipped for this twin: ${e?.message}`)
297
+ twinWarn(`[twin-kpi] "${row.instanceId}": location hierarchy is broken — area rollup skipped for this twin: ${e?.message}`)
297
298
  continue
298
299
  }
299
300
  for (const n of locations) {
@@ -483,22 +484,110 @@ async function latestEventTime(domainId: string, instanceIds: string[]): Promise
483
484
  */
484
485
  async function fetchEvents(domainId: string, instanceIds: string[], fromMs: number, toMs: number, lookbackMs: number) {
485
486
  if (instanceIds.length === 0) return { events: [] as KpiEvent[], capped: false }
486
- const rows = await getRepository(TwinEvent).find({
487
- where: {
488
- domain: { id: domainId },
489
- instanceId: In(instanceIds),
490
- eventType: In(BUSINESS_EVENTS),
491
- eventTime: Between(new Date(fromMs - lookbackMs), new Date(toMs))
492
- },
493
- order: { revision: 'ASC' },
494
- take: MAX_EVENTS
495
- })
487
+ /*
488
+ * ── 필요한 세 컬럼만, 그리고 **읽기 연결로** (2026-08-22 실측) ─────────────
489
+ * 읽기가 서버를 붙잡고 있었다. 실측(개발 DB 16.3GB · 저널 1,134만 행): 2만 행을 실어 오는 동안
490
+ * 다른 가벼운 읽기가 **47.6초** 기다렸다. 로그에서 이 자리 뒤로 사용자·도메인 조회까지 30.9초로
491
+ * 찍혔다 — TypeORM sqlite 드라이버는 연결을 하나만 들기 때문이다.
492
+ *
493
+ * **행 단위로 읽는 것 자체는 정당하다**: 이 폴드는 `p10·p50·p90` 을 낸다(§`kpi-fold`). 분위수는
494
+ * 카운터로 만들 없고 트윈별 사전집계로도 만들 수 없다(p90 의 평균은 p90 이 아니다). 개별 값이
495
+ * 있어야 한다.
496
+ *
497
+ * 정당하지 않은 것은 **읽는 방식**이었다. 세 가지를 고친다.
498
+ *
499
+ * ① **엔티티를 만들지 않는다.** 폴드가 쓰는 것은 `eventType`·`eventTime`·`payload` 뿐인데
500
+ * `find()` 는 열여섯 컬럼을 실어 엔티티로 만들고 관계 로더를 지난다. CPU 프로파일에서
501
+ * `RawSqlResultsToEntityTransformer` 와 `stringToSimpleJson` 이 상위였다.
502
+ * ② **읽기 연결로 보낸다**(`getReadRepository`). 순수 읽기이므로 쓰기와 순서를 다툴 것이 없다.
503
+ * 실측: 같은 2만 행 읽기에서 다른 읽기의 대기가 47.6초 → 2.8초.
504
+ * ③ **정렬 축을 거르는 축과 맞춘다.** 여기가 이 자리의 진짜 비용이었다.
505
+ *
506
+ * ── `ORDER BY revision` 이 `LIMIT` 을 무력화했다 (실측) ────────────────────
507
+ * 거르는 축은 `event_time`(창)이고 정렬 축은 `revision` 이었다. 그러면 sqlite 는 창에 맞는 행을
508
+ * **전부** 찾아 `revision` 으로 **전부 정렬한 뒤** 상한을 적용한다 — 상한이 정렬 뒤에 걸리므로
509
+ * 아무것도 줄여 주지 않는다. 실측이 그것을 그대로 보였다(인스턴스 하나가 375만 행):
510
+ *
511
+ * ORDER BY revision LIMIT 20000 → 32.2초 · LIMIT 5000 → 28.3초 · LIMIT 2000 → 28.2초
512
+ * ORDER BY eventTime, revision LIMIT 20000 → 0.41초 (78배)
513
+ *
514
+ * 계획도 그렇게 말한다: 앞은 `USE TEMP B-TREE FOR ORDER BY`(전체 정렬), 뒤는
515
+ * `USE TEMP B-TREE FOR LAST TERM OF ORDER BY` — 시각은 인덱스(`ix_twin_event_1`)에서 순서대로
516
+ * 나오고 **동시각만** 임시 정렬한다. 즉 범위를 훑다가 상한에서 멈춘다.
517
+ *
518
+ * ── 순서의 뜻은 **완전히 같지는 않다** (측정해서 고친 문장) ────────────────
519
+ * 처음에 「순서의 뜻은 같다」고 적었다. 재 보니 아니었다 — `revision` 순서와 `eventTime` 순서가
520
+ * 어긋나는 자리가 실재한다(hatio-mx2: 426만 행 중 **7건** · rosarito-mes: 11만 중 8건). 미러는
521
+ * 원본의 시각을 싣고 도착 순서로 리비전을 받으므로, 늦게 도착한 사건이 그 어긋남을 만든다.
522
+ *
523
+ * **그러나 폴드의 수치는 같다.** `foldTaskRecords`(커널 `task-fold.ts`)가 스트림 위치가 아니라
524
+ * **시각으로** 판정한다:
525
+ *
526
+ * startedMs = Math.min(startedMs, at)
527
+ * completedMs = Math.max(completedMs, at)
528
+ *
529
+ * `min`·`max` 이므로 행이 어떤 순서로 와도 답이 같다. 소요시간·분위수·처리량은 순서와 무관하다.
530
+ *
531
+ * 순서에 민감한 자리는 **하나**다: 그 폴드의 `location` 이 「마지막에 본 것」이다. 같은 밀리초에 같은
532
+ * 작업의 자리가 두 번 바뀌면 어느 것이 남는지가 순서로 갈린다(라이브 미러가 같은 밀리초에 여러 행을
533
+ * 보내는 경우다). 수치가 아니라 **표시되는 자리 하나**이고, 그래서 이 바꿈의 값이 그 위험보다 크다.
534
+ *
535
+ * `revision` 을 둘째 축으로 남기는 이유도 그것이다 — 동시각의 순서가 실행마다 달라지지 않게 한다.
536
+ *
537
+ * **상한을 낮추는 것으로는 고쳐지지 않는다**(위 숫자가 그것을 말한다). 정렬 축이 원인이다.
538
+ *
539
+ * 남는 비용을 감추지 않는다: `payload` 파싱은 여전히 메인 스레드 일이고 연결을 나눠도 나뉘지 않는다
540
+ * (실측 평균 1.5초). 그것을 더 줄이려면 폴드가 payload 에서 무엇을 쓰는지 좁혀야 한다 — 별 건이다.
541
+ */
542
+ const raw = await getReadRepository(TwinEvent)
543
+ .createQueryBuilder('e')
544
+ .select(['e.eventType AS "eventType"', 'e.eventTime AS "eventTime"', 'e.payload AS "payload"'])
545
+ .where('e.domain = :domainId', { domainId })
546
+ .andWhere('e.instanceId IN (:...instanceIds)', { instanceIds })
547
+ .andWhere('e.eventType IN (:...types)', { types: BUSINESS_EVENTS })
548
+ .andWhere('e.eventTime BETWEEN :from AND :to', { from: new Date(fromMs - lookbackMs), to: new Date(toMs) })
549
+ .orderBy('e.eventTime', 'ASC')
550
+ .addOrderBy('e.revision', 'ASC')
551
+ .limit(MAX_EVENTS)
552
+ .getRawMany<{ eventType: string; eventTime: unknown; payload: unknown }>()
553
+
496
554
  /* 상한에 정확히 닿으면 **더 있었을 수 있다** — 그 사실을 돌려준다. 조용히 잘라내면 숫자가 조용히
497
555
  * 작아지고, 사용자는 그것을 성과 하락으로 읽는다(가장 나쁜 종류의 거짓이다). */
498
556
  return {
499
557
  /* 폴드는 ISO 문자열 축으로 수행된다(kpi-fold) — 경계에서 한 번만 옮긴다. */
500
- events: rows.map(r => ({ eventType: r.eventType, eventTime: r.eventTime?.toISOString(), payload: r.payload })),
501
- capped: rows.length >= MAX_EVENTS
558
+ events: raw.map(r => ({ eventType: r.eventType, eventTime: isoOf(r.eventTime), payload: jsonOf(r.payload) })),
559
+ capped: raw.length >= MAX_EVENTS
560
+ }
561
+ }
562
+
563
+ /**
564
+ * 원시 결과의 시각을 ISO 로 — **드라이버마다 모양이 다르다.**
565
+ *
566
+ * 엔티티 경로에서는 TypeORM 이 `Date` 로 바꿔 주지만, 원시 결과는 드라이버가 준 그대로다(sqlite 는
567
+ * `'2026-08-07 11:00:00.000'`, pg 는 `Date`). 경계에서 한 번 정규화한다 — 폴드는 ISO 문자열만 안다.
568
+ */
569
+ function isoOf(v: unknown): string | undefined {
570
+ if (v == null) return undefined
571
+ if (v instanceof Date) return v.toISOString()
572
+ const s = String(v)
573
+ /* sqlite 의 공백 구분 형식을 ISO 로 — 그대로 `Date` 에 주면 시간대 해석이 갈린다. */
574
+ const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + (s.endsWith('Z') ? '' : 'Z'))
575
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
576
+ }
577
+
578
+ /**
579
+ * 원시 결과의 `payload` 를 객체로 — **엔티티 경로의 `simple-json` 변환을 여기서 한다.**
580
+ *
581
+ * 깨진 JSON 은 **버리지 않고 비운다**: 그 사건이 있었다는 사실은 남고, 안을 읽을 수 없다는 것만
582
+ * 폴드에 전해진다(폴드는 없는 필드를 건너뛴다). 던지면 창 하나가 통째로 사라진다.
583
+ */
584
+ function jsonOf(v: unknown): any {
585
+ if (v == null) return undefined
586
+ if (typeof v !== 'string') return v
587
+ try {
588
+ return JSON.parse(v)
589
+ } catch {
590
+ return undefined
502
591
  }
503
592
  }
504
593
 
@@ -597,6 +686,31 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
597
686
  ...shiftCtx,
598
687
  groupLimit: input.groupLimit
599
688
  })
689
+ /*
690
+ * **완료로 세지 않은 오더 상태 낱말을 말한다** (2026-08-23).
691
+ *
692
+ * 폴드는 순수하다(로그를 모른다) — 그래서 부르는 쪽이 말한다. 왜 말해야 하나: 이 폴드는 오더의
693
+ * 완료를 **동의어 목록**으로 판정하고, 그것은 원본의 낱말에서 뜻을 추론하는 것이다. 커넥터가
694
+ * 자기 표로 옮기지 않으면 그 낱말이 목록에 없어 **완료가 조용히 0 으로 세어진다.**
695
+ *
696
+ * 실제로 그랬다: 첫 실 원본의 `FINISHED` 가 목록에 없어 완료 오더가 전부 0 이었고, 사람이 화면을
697
+ * 보고 의심해서야 드러났다(그 커넥터에는 상태 표가 정의돼 **있으면서** 오더 경로에서 쓰이지 않았다).
698
+ * 그때 목록에 낱말을 더한 것은 그 사실을 가린 땜빵이었다.
699
+ *
700
+ * 그러니 이 로그는 「목록을 늘려라」가 아니라 **「그 커넥터가 옮기지 않았다」**를 뜻한다. 화면을 보지
701
+ * 않는 사람도 알아야 하므로 로그에 남긴다 — 조용한 0 이 이 시스템에서 가장 비싼 침묵이다.
702
+ *
703
+ * 완료가 하나도 없을 때만 말한다: `picking` 처럼 완료가 아닌 낱말이 섞여 있는 것은 정상이고,
704
+ * 그것까지 매번 경고하면 진짜 신호가 묻힌다.
705
+ */
706
+ const notCounted = Object.entries(kpi.orderStatusNotCounted ?? {})
707
+ if (notCounted.length && kpi.throughput.orders === 0) {
708
+ twinWarn(
709
+ `[twin-kpi] no order counted as completed in this window, but these order status words were seen: ` +
710
+ `${notCounted.map(([w, n]) => `${w}×${n}`).join(' · ')} — the connector may not be translating them ` +
711
+ `to the kernel's vocabulary (see its own status table).`
712
+ )
713
+ }
600
714
  /*
601
715
  * 목표를 **폴드 뒤, 그룹 조립 앞**에 읽는다 — 전체 판정과 축별 판정이 같은 목록에서 나와야
602
716
  * 두 곳의 임계가 갈라지지 않는다. 폴드 자체는 목표를 모른다(사실 층과 판정 층의 분리).
@@ -1,3 +1,4 @@
1
+ import { twinWarn } from './log.js'
1
2
  /*
2
3
  * 「이 트윈에 계측이 들어오고 있나」 — **그 사실을 한 자리에서 묻는다.**
3
4
  *
@@ -35,7 +36,7 @@ export function liveFeedAttached(instanceId: string): boolean {
35
36
  try {
36
37
  if (probe().includes(instanceId)) return true
37
38
  } catch (e: any) {
38
- console.warn('[twin-live] a feed registry could not answer —', e?.message ?? e)
39
+ twinWarn('[twin-live] a feed registry could not answer —', e?.message ?? e)
39
40
  }
40
41
  }
41
42
  return false
@@ -8,11 +8,11 @@
8
8
  *
9
9
  * 실제로 그런 상태를 만났다: 포트는 LISTEN 이고 webpack 은 `compiled successfully` 인데 요청이 하나도
10
10
  * 처리되지 않고 CPU 만 99% 였다. **터지면 알지만 느려지면 몰랐다** — `tickGuarded` 는 예외만 잡고
11
- * 시간은 재지 않았고, `TwinMetrics` 에는 라이브 지표(유입·방송·저널)뿐이라 시뮬 틱은 아무도 안 봤다.
11
+ * 시간은 재지 않았고, `TwinMetrics` 에는 라이브 지표(유입·브로드캐스팅·저널)뿐이라 시뮬 틱은 아무도 안 봤다.
12
12
  *
13
13
  * ── 총량만으로는 부족하다 ─────────────────────────────────────────────────
14
14
  * "틱이 800ms 걸렸다" 는 어디를 고칠지 알려 주지 않는다. 한 주기는 여러 작업으로 이뤄진다 —
15
- * 커널 틱 · 상태 투영(snapshot) · 방송 payload 만들기 · 발행 · 저널 기록 · 유입 흡수. 그래서
15
+ * 커널 틱 · 상태 투영(snapshot) · 브로드캐스팅 payload 만들기 · 발행 · 저널 기록 · 유입 흡수. 그래서
16
16
  * **작업마다 따로** 잰다. 어느 작업이 총 시간의 몇 %인지 보이면 손댈 곳이 정해진다.
17
17
  *
18
18
  * 재고 말하는 것이 먼저다. 워커 스레드로 옮기는 것은 그다음이다 — 무엇이 느린지 모른 채 옮기면
@@ -20,7 +20,16 @@
20
20
  */
21
21
 
22
22
  /** 작업 이름 — 새 작업을 재기 시작하면 여기 늘린다(문자열을 자유롭게 쓰지 않는다: 오타가 새 칸이 된다). */
23
- export const LOAD_PHASES = ['tick', 'snapshot', 'deltas', 'publish', 'journal', 'ingest', 'forecast', 'forkTick'] as const
23
+ /*
24
+ * ── 부팅 구간을 재는 두 이름 (2026-08-22) ───────────────────────────────────
25
+ * 실측으로 부팅이 620~717초였고 트윈 하나를 세우는 데 15~93초였다. 그런데 **어느 작업이 그 시간을
26
+ * 쓰는지 아무 계기도 답하지 못했다** — 재는 이름이 도는 중의 작업(틱·브로드캐스팅·저널)뿐이었다.
27
+ * 짐작으로 원인을 적으면 그것이 사실로 읽히므로, 그 자리에 이름을 둔다.
28
+ *
29
+ * · `warmStart` — 저장된 상태를 커널에 심는다(물품·오더·작업·주목 시각).
30
+ * · `stimulus` — 선언된 자극을 싣고 시작한다(실측: 큰 트윈에서 이 뒤가 29~72초였다).
31
+ */
32
+ export const LOAD_PHASES = ['tick', 'snapshot', 'deltas', 'publish', 'journal', 'ingest', 'forecast', 'forkTick', 'warmStart', 'stimulus'] as const
24
33
  export type LoadPhase = (typeof LOAD_PHASES)[number]
25
34
 
26
35
  export interface PhaseStats {
@@ -0,0 +1,72 @@
1
+ /*
2
+ * 트윈 로그의 문 — **모든 줄에 시각을 찍는다.** 순수(프레임워크 의존 없음).
3
+ *
4
+ * ── 왜 필요한가 (2026-08-22) ────────────────────────────────────────────────
5
+ * 「서버가 왜 느린가」를 조사하면서 루프가 55초 막힌 것을 계기로 알아냈는데(`loop-lag.ts`),
6
+ * **그 시각에 무슨 일이 있었는지 로그에서 찾을 수 없었다.** 트윈의 로그가 `console.log` 로 나가
7
+ * 시각이 없었기 때문이다. 프레임워크 로그(`2026-08-22T05:54:12+09:00 info: …`)와 섞여 있어서, 어느
8
+ * 줄이 언제 찍혔는지 앞뒤 프레임워크 줄로 짐작해야 했다.
9
+ *
10
+ * ── 왜 프레임워크 로거를 그대로 쓰지 않나 ──────────────────────────────────
11
+ * 재발명을 피하려고 `@things-factory/env` 의 `logger` 를 쓰려 했는데, 실측으로 막혔다: 앱 **밖**에서는
12
+ * 그 로거에 전송지가 하나도 없다(설정이 앱에서 주입된다). 그래서 시험·도구에서 부르면
13
+ *
14
+ * [winston] Attempt to write logs with no transports … {"message":"…","level":"info"}
15
+ *
16
+ * 가 나오고 **메시지 자체가 사라진다.** 트윈 엔진은 시험과 CLI 도구에서도 불려 다니므로, 그 조건에서
17
+ * 로그가 없어지는 것은 바꿔선 안 되는 거동이다(로그가 사라지는 것이 지금 문제의 원인이었다).
18
+ *
19
+ * 그래서 **싱크는 그대로 두고**(console) 빠진 사실 하나만 채운다 — 시각. 형식은 프레임워크와 같은
20
+ * 모양으로 맞춰 한 파일에서 섞여 읽히게 하고, **밀리초까지** 남긴다(계기의 값이 epoch ms 라
21
+ * 초 단위로는 같은 초 안의 순서를 가릴 수 없다).
22
+ */
23
+
24
+ /** 두 자리·세 자리 0 채우기 — 형식이 흔들리면 정렬해 읽을 수 없다. */
25
+ const p2 = (n: number) => String(n).padStart(2, '0')
26
+ const p3 = (n: number) => String(n).padStart(3, '0')
27
+
28
+ /**
29
+ * 지역 시각 + 오프셋 — `2026-08-22T05:54:12.345+09:00`.
30
+ *
31
+ * UTC 로 찍지 않는다: 이 로그를 읽는 사람은 현장의 시각으로 이야기하고, 프레임워크의 다른 줄도
32
+ * 지역 시각이다. 두 줄이 다른 기준이면 나란히 놓고 읽을 수 없다.
33
+ */
34
+ export function stamp(at: Date = new Date()): string {
35
+ const off = -at.getTimezoneOffset()
36
+ const sign = off >= 0 ? '+' : '-'
37
+ const abs = Math.abs(off)
38
+ return (
39
+ `${at.getFullYear()}-${p2(at.getMonth() + 1)}-${p2(at.getDate())}` +
40
+ `T${p2(at.getHours())}:${p2(at.getMinutes())}:${p2(at.getSeconds())}.${p3(at.getMilliseconds())}` +
41
+ `${sign}${p2(Math.floor(abs / 60))}:${p2(abs % 60)}`
42
+ )
43
+ }
44
+
45
+ /**
46
+ * 인자 여럿을 한 줄로 — **어떤 인자도 버리지 않는다.**
47
+ *
48
+ * `console.error(msg, err)` 처럼 쓰던 자리가 많다. 프레임워크 로거에 그대로 넘기면 두 번째 인자는
49
+ * meta 로 들어가 우리 형식(`message` 만 찍는다)에서 **조용히 사라진다.** 그래서 여기서 문자열로 편다.
50
+ * 오류는 스택까지 — 스택이 없으면 「어디서 났나」를 잃는다.
51
+ */
52
+ export function line(args: unknown[]): string {
53
+ return args
54
+ .map(a => {
55
+ if (typeof a === 'string') return a
56
+ if (a instanceof Error) return a.stack ?? a.message
57
+ if (a === undefined) return 'undefined'
58
+ if (a === null) return 'null'
59
+ try {
60
+ return typeof a === 'object' ? JSON.stringify(a) : String(a)
61
+ } catch {
62
+ /* 순환 참조 등 — 값을 못 적어도 **무엇이 있었다는 사실**은 남긴다. */
63
+ return String(a)
64
+ }
65
+ })
66
+ .join(' ')
67
+ }
68
+
69
+ /* 수준 표기는 프레임워크와 같은 낱말을 쓴다(`info`·`warn`·`error`) — 같은 파일에서 걸러 읽는다. */
70
+ export const twinLog = (...args: unknown[]): void => console.log(`${stamp()} info: ${line(args)}`)
71
+ export const twinWarn = (...args: unknown[]): void => console.warn(`${stamp()} warn: ${line(args)}`)
72
+ export const twinError = (...args: unknown[]): void => console.error(`${stamp()} error: ${line(args)}`)
@@ -0,0 +1,120 @@
1
+ /*
2
+ * 이벤트 루프 지체 — **화면이 느린 이유를 지어내지 않기 위해.** 순수(import 없음).
3
+ *
4
+ * ── 왜 필요한가 (2026-08-21) ────────────────────────────────────────────────
5
+ * 「UI 반응성이 느리다」를 진단하려 했는데, 있는 계기판으로는 답이 나오지 않았다. `twinLoad` 는 **트윈의
6
+ * 계측된 작업**만 재고, `processCpuRatio` 는 프로세스 전체를 재지만 **언제 얼마나 멈췄나**를 말하지
7
+ * 못한다. 밖에서 HTTP 지연을 재 보니 같은 요청이 44ms 와 5.8초로 갈라졌는데, 그 5.8초가 트윈 때문인지
8
+ * webpack 재빌드 때문인지 남의 재기동 때문인지 **가릴 근거가 없었다.**
9
+ *
10
+ * 근거 없이 원인을 고르면 엉뚱한 곳을 고친다. 그래서 루프가 실제로 얼마나 막혔는지를 잰다.
11
+ *
12
+ * ── 어떻게 재나 ─────────────────────────────────────────────────────────────
13
+ * 일정 간격으로 깨어나는 타이머를 두고, **예정 시각과 실제 시각의 차이**를 본다. 루프가 막혀 있었으면
14
+ * 그만큼 늦게 깨어난다. 재는 비용은 그 차이 계산 하나뿐이다(창마다 값 다섯).
15
+ *
16
+ * 이 값은 **원인을 말하지 않는다** — 「200ms 이상 막힌 일이 이 창에서 세 번, 가장 긴 것은 4.6초」까지가
17
+ * 사실이다. 원인은 그 시각의 트윈 부하(`twinLoad`)와 프로세스 CPU 와 나란히 놓고 사람이 가린다.
18
+ * 재지 않은 것을 0 으로 적지 않는다 — 한 번도 깨어나지 않았으면 `null` 이다.
19
+ */
20
+
21
+ /** 표본 간격(ms) — 촘촘할 필요가 없다. 긴 정체를 놓치지 않는 정도면 된다. */
22
+ export const LAG_SAMPLE_MS = 500
23
+ /** 이 값을 넘는 지체만 「정체」로 센다(ms). 사람이 화면에서 느끼기 시작하는 크기. */
24
+ export const LAG_STALL_MS = 200
25
+ /** 창 길이(ms) — `twinLoad` 의 창과 같은 크기로 두어 나란히 읽는다. */
26
+ export const LAG_WINDOW_MS = 10_000
27
+
28
+ export interface LoopLagWindow {
29
+ /** 이 창에서 가장 길게 막힌 시간(ms). */
30
+ maxMs: number
31
+ /** 정체(문턱 초과) 횟수. */
32
+ stalls: number
33
+ /** 표본 수 — 분모다. */
34
+ samples: number
35
+ /** 지체 합(ms) — 평균은 읽는 쪽에서 낸다. */
36
+ totalMs: number
37
+ /** 창이 열린 시각. */
38
+ startMs: number
39
+ }
40
+
41
+ export interface LoopLagView {
42
+ /** 마지막으로 닫힌 창 — 「지금 어떤가」. 아직 한 창도 안 지났으면 null. */
43
+ recent: (LoopLagWindow & { endMs: number }) | null
44
+ /** 기동 이후 누적. */
45
+ total: LoopLagWindow
46
+ /** 기동 이후 가장 길게 막힌 시각(ms, epoch) — 언제였는지가 원인을 가리는 데 쓰인다. */
47
+ worstAtMs: number | null
48
+ /** 표본 간격 — 읽는 쪽이 이 값보다 짧은 정체는 못 본다는 것을 알아야 한다. */
49
+ sampleMs: number
50
+ /** 정체로 세는 문턱. */
51
+ stallMs: number
52
+ }
53
+
54
+ const emptyWindow = (startMs: number): LoopLagWindow => ({ maxMs: 0, stalls: 0, samples: 0, totalMs: 0, startMs })
55
+
56
+ export class LoopLagSampler {
57
+ private window: LoopLagWindow
58
+ private closed: (LoopLagWindow & { endMs: number }) | null = null
59
+ private total: LoopLagWindow
60
+ private worstAtMs: number | null = null
61
+ private timer: any
62
+ private expectedMs: number
63
+
64
+ private sampleMs: number
65
+ private stallMs: number
66
+ private windowMs: number
67
+ private now: () => number
68
+
69
+ /* 생성자 파라미터 프로퍼티(`private x = …`)는 쓰지 않는다 — 시험이 소스를 그대로 실행하는데
70
+ (타입 제거 실행 방식) 그 문법은 지원되지 않아 파일 전체가 열리지 않는다. */
71
+ constructor(sampleMs = LAG_SAMPLE_MS, stallMs = LAG_STALL_MS, windowMs = LAG_WINDOW_MS, now: () => number = Date.now) {
72
+ this.sampleMs = sampleMs
73
+ this.stallMs = stallMs
74
+ this.windowMs = windowMs
75
+ this.now = now
76
+ const t = this.now()
77
+ this.window = emptyWindow(t)
78
+ this.total = emptyWindow(t)
79
+ this.expectedMs = t + sampleMs
80
+ }
81
+
82
+ /** 표본을 하나 넣는다 — 타이머가 부르고, 시험은 직접 부른다(시계를 밀어 넣어 잰다). */
83
+ sample(atMs = this.now()): number {
84
+ const lag = Math.max(0, atMs - this.expectedMs)
85
+ this.expectedMs = atMs + this.sampleMs
86
+ for (const w of [this.window, this.total]) {
87
+ w.samples += 1
88
+ w.totalMs += lag
89
+ if (lag > this.stallMs) w.stalls += 1
90
+ if (lag > w.maxMs) w.maxMs = lag
91
+ }
92
+ if (lag >= this.total.maxMs && lag > this.stallMs) this.worstAtMs = atMs
93
+ if (atMs - this.window.startMs >= this.windowMs) {
94
+ this.closed = { ...this.window, endMs: atMs }
95
+ this.window = emptyWindow(atMs)
96
+ }
97
+ return lag
98
+ }
99
+
100
+ /** 계기판이 읽는 값. **한 창도 닫히지 않았으면 `recent` 는 null** — 채우는 중인 창은 과소 집계다. */
101
+ view(): LoopLagView {
102
+ return { recent: this.closed, total: this.total, worstAtMs: this.worstAtMs, sampleMs: this.sampleMs, stallMs: this.stallMs }
103
+ }
104
+
105
+ /** 표본 타이머 기동(1회) — 프로세스 종료를 막지 않는다. */
106
+ start(): void {
107
+ if (this.timer) return
108
+ this.timer = setInterval(() => this.sample(), this.sampleMs)
109
+ if (typeof this.timer?.unref === 'function') this.timer.unref()
110
+ }
111
+
112
+ stop(): void {
113
+ if (!this.timer) return
114
+ clearInterval(this.timer)
115
+ this.timer = undefined
116
+ }
117
+ }
118
+
119
+ /** 프로세스 하나의 루프를 재는 것이므로 표본기도 하나다. */
120
+ export const loopLag = new LoopLagSampler()
@@ -0,0 +1,168 @@
1
+ /*
2
+ * **원본이 말한 적 없는 것** — 미해결 참조를 세는 한 자리. 순수(import 없음).
3
+ *
4
+ * ── 무엇을 답하나 ───────────────────────────────────────────────────────────
5
+ * 원천이 우리 모델에 없는 id 를 보내면 어떻게 되나. 유입 문(`ingestCanonicalRecords`)은 **거부하지
6
+ * 않는다** — 실측으로 확인했다(2026-08-20):
7
+ *
8
+ * 모르는 로케이션 「오타-99」 → 통과
9
+ * 모르는 bizStep 「PUTAWAY_X17」 → 통과
10
+ * 모르는 계량기 「MMXU-오타-99」 → 통과
11
+ *
12
+ * 거부하지 않는 것이 옳다. 원천이 모델보다 먼저 새 로케이션을 말하는 것은 정상이고(구조 갱신은 늦게 온다),
13
+ * 거부하면 그 선행 계측을 잃는다. 그래서 커널은 **흡수하고 표시한다** — 마스터에 없던 로케이션은
14
+ * `origin: 'observed'` 로 승격되어 스냅샷에 실린다(`ObservedReducer.touchLocation`).
15
+ *
16
+ * ── 왜 이 파일이 있나 ───────────────────────────────────────────────────────
17
+ * 그 표시를 읽는 곳이 **모델 인스펙션 화면 하나**였다. 그런데 이 사실을 필요로 하는 사람은 「이 트윈이
18
+ * 현장과 맞춰지고 있나」를 보는 사람이다 — 원천이 우리가 모르는 로케이션만 보내고 있으면 트윈은 도는데
19
+ * 스키매틱은 비어 있고, 그것이 우리가 「빈 공장」이라 불러 온 상태다.
20
+ *
21
+ * 도출을 두 곳에 적으면 두 화면이 다른 수를 말하게 된다. 그래서 여기 한 벌만 둔다.
22
+ *
23
+ * ── 판정을 바꾸지 않는다 ────────────────────────────────────────────────────
24
+ * 동기화 판정(`syncVerdictOf`)은 **피드**를 말한다: 오고 있나, 통과하나. 이 갭은 **모델**을 말한다:
25
+ * 원천이 아는 것을 우리가 선언했나. 둘을 한 판정에 섞으면 「유입은 정상인데 모델이 뒤처졌다」와
26
+ * 「유입이 막혔다」가 같은 색으로 보이고, 처방이 완전히 다르다.
27
+ */
28
+
29
+ /** 커널 스냅샷에서 우리가 읽는 것만. 나머지 필드는 알 필요가 없다. */
30
+ export interface ModelGapSource {
31
+ locations?: { id?: string; origin?: string }[]
32
+ equipment?: { id?: string; origin?: string }[]
33
+ persons?: { id?: string; origin?: string }[]
34
+ /** 커널이 담을 줄 몰라 세어 둔 사건 — 종류별 개수와 처음·마지막 시각. */
35
+ unhandled?: { eventType: string; count: number; firstAtMs?: number; lastAtMs?: number }[]
36
+ /**
37
+ * EMS 상태 — **`energy` 안에 있다.**
38
+ *
39
+ * `unknownEquipment` 를 루트에서 읽고 있었다(2026-08-20에 잡음). 커널은 그것을 `EnergyState` 안에
40
+ * 담으므로, 루트에서 읽으면 **영원히 `null`** 이다 — 오류도 없이 「에너지 트윈이 아니다」로 읽힌다.
41
+ * 나만 읽는 필드를 잘못된 경로에서 읽으면 이렇게 조용히 사라진다.
42
+ *
43
+ * 계량 지점의 `origin` 은 **없을 수 있다**(옛 스냅샷에서 이어받은 지점). 그때는 「선언되었다」가
44
+ * 아니라 **모른다**다.
45
+ */
46
+ energy?: {
47
+ points?: { id?: string; origin?: string }[]
48
+ /** 모델이 모르는 설비가 에너지 상태를 보내 온 횟수. 0 이면 커널이 이 칸을 만들지 않는다. */
49
+ unknownEquipment?: number
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 화면에 내는 id 수 상한.
55
+ *
56
+ * 오타 하나를 찾는 데 스무 개면 충분하고, 원천이 수천 개를 보내는 경우(모델을 아예 안 맞춘 경우)에
57
+ * 목록으로 화면을 채울 이유가 없다. **넘친 수는 함께 낸다** — 조용히 자르지 않는다.
58
+ */
59
+ export const GAP_ID_LIMIT = 20
60
+
61
+ export interface ModelGapAxis {
62
+ /** 관측으로만 알게 된 id 수. */
63
+ count: number
64
+ /** 그중 화면에 싣는 것(최대 `GAP_ID_LIMIT`). */
65
+ ids: string[]
66
+ /** 상한에서 밀려난 수. 0 이면 전부 실었다. */
67
+ more: number
68
+ /**
69
+ * 출처를 **모르는** 수 — 표시가 없는 항목.
70
+ *
71
+ * 「선언되었다」로 세지 않는다. 계량기는 이 표시가 늦게 생겼으므로 옛 스냅샷에서 이어받은 지점에는
72
+ * 칸이 없다(`undefined`). 그것을 마스터로 세면 오타를 놓치고, 관측으로 세면 정상을 문제로 만든다.
73
+ * 둘 다 틀리므로 **모른다고 센다.**
74
+ */
75
+ unknown: number
76
+ }
77
+
78
+ export interface ModelGapView {
79
+ locations: ModelGapAxis
80
+ equipment: ModelGapAxis
81
+ persons: ModelGapAxis
82
+ /**
83
+ * EMS 계량 지점.
84
+ *
85
+ * ── 이 축이 가장 무겁다 ────────────────────────────────────────────────────
86
+ * 모르는 `meterId` 는 조용히 새 계량기가 되고 그 kW 가 그 현장의 **수요 구간·피크·요금 판정**에
87
+ * 더해진다. 오타 하나면 진짜 계량기는 멈추고 유령이 누적된다 — 숫자는 그럴듯하고 틀린다. 자리 오타는
88
+ * 스키매틱이 비는 것으로 드러나지만, 이건 그럴듯한 숫자로 나온다.
89
+ *
90
+ * 커널이 `origin` 을 **표본마다 다시 맞춘다** — 선언의 정본은 모델이고 모델은 채택으로 늘어나므로,
91
+ * 한 번 찍고 두면 나중에 선언된 계량기가 영원히 `observed` 로 남는다. 그래서 이 값은 「지금 모델에
92
+ * 있는가」의 답이고, 「한 번 오타였다」의 답이 아니다.
93
+ */
94
+ meters: ModelGapAxis
95
+ /** 세 축의 합 — 화면이 「있나 없나」를 한 번에 묻기 위한 값. */
96
+ total: number
97
+ /** 커널이 담을 줄 모른 사건들(종류별). 참조 문제와 다른 사실이므로 따로 낸다. */
98
+ unhandled: { eventType: string; count: number; lastAt: string | null }[]
99
+ /** EMS: 모델이 모르는 설비의 에너지 보고 횟수. 없으면 `null`(0 과 구별한다). */
100
+ unknownEquipmentReports: number | null
101
+ /**
102
+ * 아직 덮지 못하는 축 — **모르는 것을 모른다고 말한다.**
103
+ *
104
+ * 계량기는 커널 0.7.39 부터 덮인다(그 전에는 `origin` 표시가 없어 오타를 찾아 줄 수 없었다).
105
+ * 비어 있는 것이 정상이고, 새 축이 표시 없이 들어오면 여기에 이름을 넣는다 — 화면이 「다 찾아
106
+ * 준다」고 말하지 않게.
107
+ */
108
+ notCovered: string[]
109
+ }
110
+
111
+ /** 계량기 축이 아직 덮이지 않는 이유 — 화면이 그대로 말할 수 있게 이름을 여기 둔다. */
112
+ export const NOT_COVERED_METERS = 'meters'
113
+
114
+ function axisOf(rows: { id?: string; origin?: string }[] | undefined): ModelGapAxis {
115
+ const ids: string[] = []
116
+ let count = 0
117
+ let unknown = 0
118
+ for (const r of rows ?? []) {
119
+ if (r?.origin === 'observed') {
120
+ count++
121
+ if (ids.length < GAP_ID_LIMIT) ids.push(String(r.id ?? ''))
122
+ } else if (r?.origin !== 'master') {
123
+ /* 표시가 없다 — 마스터로도 관측으로도 세지 않는다. 모르는 것은 모른다고 센다. */
124
+ unknown++
125
+ }
126
+ }
127
+ return { count, ids, more: Math.max(0, count - ids.length), unknown }
128
+ }
129
+
130
+ /**
131
+ * 스냅샷 → 모델 갭.
132
+ *
133
+ * 스냅샷이 없으면 `null` 이다 — **0 이 아니다.** 트윈이 돌지 않으면 비교할 상대가 없고, 「없다」와
134
+ * 「세어 보지 못했다」는 다른 사실이다(모델 인스펙션이 같은 규율을 쓴다).
135
+ */
136
+ export function modelGapOf(snapshot: ModelGapSource | null | undefined): ModelGapView | null {
137
+ if (!snapshot) return null
138
+
139
+ const locations = axisOf(snapshot.locations)
140
+ const equipment = axisOf(snapshot.equipment)
141
+ const persons = axisOf(snapshot.persons)
142
+ const meters = axisOf(snapshot.energy?.points)
143
+
144
+ return {
145
+ locations,
146
+ equipment,
147
+ persons,
148
+ meters,
149
+ total: locations.count + equipment.count + persons.count + meters.count,
150
+ unhandled: (snapshot.unhandled ?? []).map(u => ({
151
+ eventType: u.eventType,
152
+ count: u.count,
153
+ /* 마지막 시각이 멈춘 종류는 이미 고쳐진 것이다. 없으면 지어내지 않는다. */
154
+ lastAt: Number.isFinite(u.lastAtMs) ? new Date(u.lastAtMs as number).toISOString() : null
155
+ })),
156
+ /*
157
+ * 커널은 0 이면 이 칸을 만들지 않는다. 그래서 「없음」은 두 뜻이다 — 에너지 트윈이 아니거나, 세었고
158
+ * 0 이었다. 둘을 가르려면 `energy` 자체가 있는지를 본다: 있으면 세었고 0 이다.
159
+ */
160
+ unknownEquipmentReports:
161
+ typeof snapshot.energy?.unknownEquipment === 'number' ? snapshot.energy.unknownEquipment : snapshot.energy ? 0 : null,
162
+ /*
163
+ * 이제 계량기도 덮는다(커널 0.7.39 가 `MeterPointState.origin` 을 낸다). 덮지 못하는 축이 생기면
164
+ * 다시 여기에 이름을 넣는다 — 화면이 「다 찾아 준다」고 말하지 않게.
165
+ */
166
+ notCovered: []
167
+ }
168
+ }
@@ -18,7 +18,7 @@
18
18
  * ── 기본값을 두지 않는다 ────────────────────────────────────────────────────
19
19
  * 예전에는 미선언이 조용히 `sim-experiment`(=저널 초기화)로 떨어졌다. 그 관용은 값이 하나 어긋나는
20
20
  * 순간 **저널을 지우는 길**이 된다 — 개명 뒤 옛 값이 남아 있는 행을 그 길로 읽으면 재기동 한 번에
21
- * 이력이 사라진다. 그래서 모르는 값은 **던진다.** 부르는 쪽이 선언하게 만드는 것이 이 축의 요점이다.
21
+ * 이력이 사라진다. 그래서 모르는 값은 **오류를 낸다.** 부르는 쪽이 선언하게 만드는 것이 이 축의 요점이다.
22
22
  */
23
23
 
24
24
  /** 재기동 정책 — 셋뿐이다. */
@@ -27,11 +27,11 @@ export type RestartPolicy = 'resync' | 'resume' | 'reset'
27
27
  export const RESTART_POLICIES: readonly RestartPolicy[] = ['resync', 'resume', 'reset'] as const
28
28
 
29
29
  /**
30
- * 저장된 값을 정책으로 읽는다 — **모르면 던진다**(기본값으로 메우지 않는다).
30
+ * 저장된 값을 정책으로 읽는다 — **모르면 오류를 낸다**(기본값으로 메우지 않는다).
31
31
  *
32
32
  * 옛 어휘(`mirror`·`sim-world`·`sim-experiment`)를 여기서 번역하지 않는다: 번역을 두면 옛 값이 살아남고,
33
33
  * 그 값이 다시 어딘가에 저장된다. 저장된 값은 **한 번 옮기고**(데이터 이관) 코드는 새 어휘만 안다.
34
- * 그래서 이 함수는 옛 값에 대해서도 던지며, 무엇으로 옮겨야 하는지 문장으로 말한다.
34
+ * 그래서 이 함수는 옛 값에 대해서도 오류를 내며, 무엇으로 옮겨야 하는지 문장으로 말한다.
35
35
  */
36
36
  export function readRestartPolicy(raw: unknown, where: string): RestartPolicy {
37
37
  const value = typeof raw === 'string' ? raw.trim() : ''
@@ -49,7 +49,7 @@ export function readRestartPolicy(raw: unknown, where: string): RestartPolicy {
49
49
  )
50
50
  }
51
51
 
52
- /** 이 값이 정책인가 — 던지지 않고 묻는 자리(화면·검사)를 위해. */
52
+ /** 이 값이 정책인가 — 오류를 내지 않고 묻는 자리(화면·검사)를 위해. */
53
53
  export function isRestartPolicy(raw: unknown): raw is RestartPolicy {
54
54
  return typeof raw === 'string' && (RESTART_POLICIES as readonly string[]).includes(raw)
55
55
  }
@@ -25,7 +25,7 @@
25
25
  * `${domainId}:${instanceId}` — 이 저장소가 이미 쓰는 모양이다(`structureRevCache`·`measuredCache`).
26
26
  * 새 모양을 만들지 않는다. 파싱은 **첫 구분자**에서 자르므로 `instanceId` 에 `:` 가 들어 있어도
27
27
  * 안전하다(레퍼런스 소스 이름에 들어갈 수 있다). 대신 `domainId` 에 구분자가 있으면 키가 모호해지므로
28
- * **그때는 던진다** — 조용히 잘못된 키를 만들면 그게 곧 이 결함의 재발이다.
28
+ * **그때는 오류를 낸다** — 조용히 잘못된 키를 만들면 그게 곧 이 결함의 재발이다.
29
29
  */
30
30
 
31
31
  /** 도메인과 인스턴스 id 를 잇는 문자 — 저장소의 기존 캐시 키와 같은 모양. */