@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,306 @@
1
+ /*
2
+ * 동기화 건강 — **「이 트윈이 현장과 맞춰지고 있나」에 정직하게 답한다.**
3
+ *
4
+ * 지키는 것: ① 판정 일곱이 서로 섞이지 않고, **끊긴 피드가 옛 창으로 초록을 내지 않으며, 버려지고 있는
5
+ * 것을 「해당 없음」으로 덮지 않는다** ② 닫힌 창만 「최근」이다 ③ 0 과 「없음」이 다르다 ④ 상한에서
6
+ * 밀려난 것을 함께 낸다 ⑤ 사유 종류가 무한히 자라지 않는다 ⑥ 같은 사유를 두 번 직렬화하지 않는다
7
+ * ⑦ 영속 훅이 장부의 진행을 막지 않는다.
8
+ */
9
+ import { test } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+
12
+ import {
13
+ HEALTHY_ACCEPTED_RATIO,
14
+ INGEST_WINDOW_MS,
15
+ OTHER_REASON,
16
+ REASON_LIMIT,
17
+ STALE_AFTER_MS,
18
+ TREND_LIMIT,
19
+ ingestHealth,
20
+ newIngestLedger,
21
+ recordIngest,
22
+ rollIngestWindow,
23
+ syncVerdictOf
24
+ } from '../server/engine/ingest-health.ts'
25
+
26
+ /* ── ① 판정 ─────────────────────────────────────────────────────────────── */
27
+
28
+ test('시뮬 트윈에는 「동기화 안 됨」이라 말하지 않는다 — 거짓 경보는 참 경보를 죽인다', () => {
29
+ assert.equal(syncVerdictOf({ feedState: 'not-applicable', recent: null }), 'not-applicable')
30
+ assert.equal(syncVerdictOf({ feedState: 'not-applicable', recent: { offered: 0, rejected: 0 } }), 'not-applicable')
31
+ })
32
+
33
+ test('피드가 없는 것과 붙었는데 안 오는 것을 가른다', () => {
34
+ assert.equal(syncVerdictOf({ feedState: 'detached', recent: null }), 'not-connected')
35
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 0, rejected: 0 } }), 'no-traffic')
36
+ })
37
+
38
+ test('재지 않은 것을 정상·비정상으로 채우지 않는다', () => {
39
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: null }), 'unknown')
40
+ })
41
+
42
+ test('오래 끊긴 피드는 건강했던 옛 창으로 초록을 내지 않는다 — 이 기능이 있는 이유', () => {
43
+ /*
44
+ * 창은 다음 유입이 있을 때 닫힌다. 유입이 멈추면 「최근 창」은 멈추기 직전의 건강했던 창으로 남는다.
45
+ * 그 창만 보면 어제 죽은 피드가 오늘도 「동기화 중」이다.
46
+ */
47
+ const healthy = { offered: 1000, rejected: 0 }
48
+
49
+ assert.equal(
50
+ syncVerdictOf({ feedState: 'attached', recent: healthy, lastAtMs: 0, nowMs: STALE_AFTER_MS + 1 }),
51
+ 'no-traffic'
52
+ )
53
+ assert.equal(
54
+ syncVerdictOf({ feedState: 'attached', recent: healthy, lastAtMs: 0, nowMs: STALE_AFTER_MS }),
55
+ 'syncing',
56
+ '경계까지는 아직 조용하다고 하지 않는다'
57
+ )
58
+ })
59
+
60
+ test('침묵이 거부보다 앞선다 — 끊긴 피드에 「매핑이 막고 있음」이라 말하지 않는다', () => {
61
+ /* 매핑을 고치라는 말은 유입이 있을 때만 맞는 처방이다. 오지 않는 것은 매핑 문제가 아니다. */
62
+ assert.equal(
63
+ syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 90 }, lastAtMs: 0, nowMs: STALE_AFTER_MS + 1 }),
64
+ 'no-traffic'
65
+ )
66
+ })
67
+
68
+ test('붙었지만 한 번도 받지 않은 것을 「유입 없음」이라 단정하지 않는다', () => {
69
+ /* `-1` 은 「없음」이다. 0(1970년)으로 읽으면 방금 띄운 트윈이 곧바로 「끊겼다」가 된다. */
70
+ assert.equal(
71
+ syncVerdictOf({ feedState: 'attached', recent: null, lastAtMs: -1, nowMs: STALE_AFTER_MS * 100 }),
72
+ 'unknown'
73
+ )
74
+ })
75
+
76
+ test('시뮬 트윈은 오래 조용해도 「유입 없음」이 아니다', () => {
77
+ assert.equal(
78
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastAtMs: -1, nowMs: STALE_AFTER_MS * 100 }),
79
+ 'not-applicable'
80
+ )
81
+ })
82
+
83
+ test('들어오는데 트윈이 안 돌면 「버려지는 중」이다 — 「해당 없음」으로 안심시키지 않는다', () => {
84
+ /*
85
+ * 실제로 겪었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 커널은 그것을 조용히 버렸다.
86
+ * 그런데 화면은 「해당 없음 · 150 통과 · 100%」였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다.
87
+ *
88
+ * 멈춘 트윈의 피드 사실은 `not-applicable` 이므로, 미전달을 **그보다 먼저** 봐야 이 상태가 드러난다.
89
+ */
90
+ assert.equal(
91
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: 0, nowMs: 1000 }),
92
+ 'twin-not-running'
93
+ )
94
+ assert.equal(
95
+ syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 0 }, lastUndeliveredAtMs: 0, nowMs: 1000 }),
96
+ 'twin-not-running',
97
+ '통과율이 좋아 보여도 버려지고 있으면 그것을 말한다'
98
+ )
99
+ })
100
+
101
+ test('버려진 지 오래됐으면 더는 「버려지는 중」이라 하지 않는다 — 지난 일을 현재로 말하지 않는다', () => {
102
+ assert.equal(
103
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: 0, nowMs: STALE_AFTER_MS + 1 }),
104
+ 'not-applicable'
105
+ )
106
+ })
107
+
108
+ test('버려진 적이 없으면 미전달 판정이 서지 않는다 — `-1` 을 시각 0 으로 읽지 않는다', () => {
109
+ assert.equal(
110
+ syncVerdictOf({ feedState: 'not-applicable', recent: null, lastUndeliveredAtMs: -1, nowMs: 1000 }),
111
+ 'not-applicable'
112
+ )
113
+ })
114
+
115
+ test('버려진 것을 「통과」로 세지 않는다 — 통과율이 그만큼 내려간다', () => {
116
+ const l = newIngestLedger()
117
+ /* 100건 제시 · 매핑 거부 0 · 그런데 40건이 트윈에 닿지 못했다 */
118
+ recordIngest(l, 100, [], 0, undefined, 40)
119
+ recordIngest(l, 1, [], INGEST_WINDOW_MS + 1) // 창을 닫는다
120
+
121
+ const v = ingestHealth(l, 'attached', undefined, INGEST_WINDOW_MS + 1)
122
+ assert.equal(v.recent!.offered, 100)
123
+ assert.equal(v.recent!.rejected, 0, '매핑은 아무것도 거부하지 않았다')
124
+ assert.equal(v.recent!.undelivered, 40)
125
+ assert.equal(v.recent!.accepted, 60, '트윈에 닿은 것만 통과다')
126
+ assert.equal(v.recent!.acceptedRatio, 0.6, '100% 가 아니다')
127
+ assert.equal(v.total.undelivered, 40)
128
+ })
129
+
130
+ test('거부가 문턱을 넘으면 매핑을 지목한다 — 문턱 경계를 정확히 지킨다', () => {
131
+ /* 문턱은 「이상이면 정상」이다. 통과율이 정확히 문턱이면 정상으로 본다. */
132
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 5 } }), 'syncing')
133
+ assert.equal(syncVerdictOf({ feedState: 'attached', recent: { offered: 100, rejected: 6 } }), 'mapping-blocked')
134
+ assert.equal(HEALTHY_ACCEPTED_RATIO, 0.95)
135
+ })
136
+
137
+ /* ── ② 창 ───────────────────────────────────────────────────────────────── */
138
+
139
+ test('채우는 중인 창을 「최근」이라 부르지 않는다', () => {
140
+ const l = newIngestLedger()
141
+ recordIngest(l, 100, [], 0)
142
+ recordIngest(l, 100, [], 1000)
143
+
144
+ /* 읽는 시각을 넘긴다 — 넘기지 않으면 벽시계와 견주어 「오래 조용함」으로 읽힌다(1970년 장부이므로). */
145
+ const v = ingestHealth(l, 'attached', undefined, 1000)
146
+ assert.equal(v.recent, null, '창이 아직 닫히지 않았다')
147
+ assert.equal(v.verdict, 'unknown', '그래서 판정도 모름이다')
148
+ assert.equal(v.total.offered, 200, '누적은 이미 센다')
149
+ })
150
+
151
+ test('창이 닫히면 최근이 되고, 통과와 거부를 함께 낸다', () => {
152
+ const l = newIngestLedger()
153
+ recordIngest(l, 200, [{ errors: ['epc is required'] }, { errors: ['epc is required'] }], 0)
154
+ recordIngest(l, 5, [], INGEST_WINDOW_MS + 1) // 창을 닫는다
155
+
156
+ const v = ingestHealth(l, 'attached', undefined, INGEST_WINDOW_MS + 1)
157
+ assert.equal(v.recent!.offered, 200)
158
+ assert.equal(v.recent!.rejected, 2)
159
+ assert.equal(v.recent!.accepted, 198)
160
+ assert.equal(v.recent!.acceptedRatio, 0.99)
161
+ assert.equal(v.verdict, 'syncing')
162
+ })
163
+
164
+ test('추이 상한을 넘으면 오래된 창이 밀려나고, 밀려난 수를 낸다', () => {
165
+ const l = newIngestLedger()
166
+ for (let i = 0; i <= TREND_LIMIT + 3; i++) recordIngest(l, 1, [], i * (INGEST_WINDOW_MS + 1))
167
+
168
+ const v = ingestHealth(l, 'attached')
169
+ assert.equal(v.trend.length, TREND_LIMIT)
170
+ assert.ok(v.trendDropped > 0, `밀려난 창을 조용히 버리지 않는다 — 실제 ${v.trendDropped}`)
171
+ })
172
+
173
+ /* ── ③ 0 과 없음 ────────────────────────────────────────────────────────── */
174
+
175
+ test('한 번도 받지 않은 트윈은 수치를 0 으로 채우지 않는다', () => {
176
+ const v = ingestHealth(newIngestLedger(), 'attached')
177
+
178
+ assert.equal(v.lastAt, null, '받은 적이 없다')
179
+ assert.equal(v.total.acceptedRatio, null, '비율이 없다 — 1 이 아니다')
180
+ assert.equal(v.recent, null)
181
+ assert.equal(v.verdict, 'unknown')
182
+ })
183
+
184
+ test('장부가 없어도 피드 사실만으로 답할 수 있는 판정은 낸다', () => {
185
+ const v = ingestHealth(undefined, 'not-applicable')
186
+
187
+ assert.equal(v.verdict, 'not-applicable')
188
+ assert.equal(v.total.acceptedRatio, null, '없는 값을 지어내지 않는다')
189
+ })
190
+
191
+ test('제시는 있고 거부가 0 이면 비율은 1 이다 — 이건 「없음」이 아니다', () => {
192
+ const l = newIngestLedger()
193
+ recordIngest(l, 50, [], 0)
194
+
195
+ assert.equal(ingestHealth(l, 'attached').total.acceptedRatio, 1)
196
+ })
197
+
198
+ test('1,000건 중 1건 거부가 「전부 통과」로 읽히지 않는다', () => {
199
+ const l = newIngestLedger()
200
+ recordIngest(l, 1000, [{ errors: ['bad'] }], 0)
201
+
202
+ assert.equal(ingestHealth(l, 'attached').total.acceptedRatio, 0.999)
203
+ })
204
+
205
+ /* ── ④⑤⑥ 상한과 비용 ───────────────────────────────────────────────────── */
206
+
207
+ test('사유 종류가 무한히 자라지 않는다 — 문구에 변수가 섞여도', () => {
208
+ const l = newIngestLedger()
209
+ for (let i = 0; i < REASON_LIMIT * 5; i++) recordIngest(l, 1, [{ errors: [`unknown bizStep "S${i}"`] }], 1)
210
+
211
+ const v = ingestHealth(l, 'attached')
212
+ assert.ok(v.reasons.length <= REASON_LIMIT + 1, `사유 칸이 ${v.reasons.length}개 — 상한 + 기타 한 칸`)
213
+ assert.equal(v.reasonsCapped, true, '접혔다는 사실을 말한다')
214
+ assert.ok(
215
+ v.reasons.some(r => r.reason === OTHER_REASON),
216
+ '접힌 것들이 갈 칸이 있다'
217
+ )
218
+ assert.equal(v.total.rejected, REASON_LIMIT * 5, '건수는 하나도 잃지 않는다')
219
+ })
220
+
221
+ test('같은 사유는 한 번만 표본을 뜬다 — 나머지는 직렬화하지 않는다', () => {
222
+ const l = newIngestLedger()
223
+ let serialized = 0
224
+ const record = () => {
225
+ serialized++
226
+ return { epc: 'x' }
227
+ }
228
+ for (let i = 0; i < 500; i++) recordIngest(l, 1, [{ errors: ['epc is required'], record: record() }], 1)
229
+
230
+ const v = ingestHealth(l, 'attached')
231
+ assert.equal(v.samples.length, 1, '사유 하나 → 표본 하나')
232
+ assert.equal(v.sampleDropped, 499, '나머지는 세어서 밝힌다')
233
+ assert.equal(v.total.rejected, 500, '건수는 그대로')
234
+ assert.equal(serialized, 500, '(레코드를 만든 횟수 — 장부가 직렬화한 횟수는 아래에서 본다)')
235
+ })
236
+
237
+ test('사유가 여럿이면 사유마다 표본이 하나씩 생긴다', () => {
238
+ const l = newIngestLedger()
239
+ recordIngest(l, 3, [{ errors: ['a'] }, { errors: ['b'] }, { errors: ['a'] }], 1)
240
+
241
+ const v = ingestHealth(l, 'attached')
242
+ assert.equal(v.samples.length, 2, 'a·b 하나씩')
243
+ assert.equal(v.sampleDropped, 1, '두 번째 a 는 안 뜬다')
244
+ })
245
+
246
+ test('긴 레코드는 자르고, 잘랐다고 표시한다', () => {
247
+ const l = newIngestLedger()
248
+ recordIngest(l, 1, [{ errors: ['big'], record: { blob: 'x'.repeat(5000) } }], 1)
249
+
250
+ const s = ingestHealth(l, 'attached').samples[0]
251
+ assert.equal(s.recordTruncated, true)
252
+ assert.ok(s.record.length <= 2000)
253
+ })
254
+
255
+ test('직렬화할 수 없는 레코드가 장부를 멈추지 않는다', () => {
256
+ const l = newIngestLedger()
257
+ const cyclic: any = {}
258
+ cyclic.self = cyclic
259
+ recordIngest(l, 1, [{ errors: ['cyclic'], record: cyclic }], 1)
260
+
261
+ assert.equal(ingestHealth(l, 'attached').total.rejected, 1, '건수는 센다')
262
+ })
263
+
264
+ /* ── ⑦ 영속 훅 ──────────────────────────────────────────────────────────── */
265
+
266
+ test('창이 닫힐 때만 영속 훅이 불린다 — 건마다가 아니다', () => {
267
+ const l = newIngestLedger()
268
+ const closed: { offered: number; rejected: number }[] = []
269
+
270
+ for (let i = 0; i < 50; i++) recordIngest(l, 10, [], i * 1000, w => closed.push(w))
271
+ assert.equal(closed.length, 0, '10분이 안 지났으면 한 번도 안 부른다')
272
+
273
+ recordIngest(l, 10, [], INGEST_WINDOW_MS + 1, w => closed.push(w))
274
+ assert.equal(closed.length, 1, '창이 닫힐 때 한 번')
275
+ assert.equal(closed[0].offered, 500, '닫힌 창의 값을 그대로 넘긴다')
276
+ })
277
+
278
+ test('영속이 실패해도 장부는 계속 굴러간다', () => {
279
+ const l = newIngestLedger()
280
+ recordIngest(l, 10, [], 0)
281
+
282
+ assert.doesNotThrow(() =>
283
+ recordIngest(l, 10, [], INGEST_WINDOW_MS + 1, () => {
284
+ throw new Error('DB down')
285
+ })
286
+ )
287
+ assert.equal(l.trend.length, 1, '창은 닫혔다')
288
+ assert.equal(ingestHealth(l, 'attached').total.offered, 20, '집계도 이어진다')
289
+ })
290
+
291
+ /* ── 입력 방어 ──────────────────────────────────────────────────────────── */
292
+
293
+ test('망가진 제시 수는 기록하지 않는다 — 통과율이 거짓이 되지 않게', () => {
294
+ const l = newIngestLedger()
295
+ recordIngest(l, -5, [], 1)
296
+ recordIngest(l, NaN, [], 1)
297
+
298
+ assert.equal(ingestHealth(l, 'attached').lastAt, null, '아무것도 안 쌓였다')
299
+ })
300
+
301
+ test('사유가 비어 있으면 지어내지 않고 unknown 으로 둔다', () => {
302
+ const l = newIngestLedger()
303
+ recordIngest(l, 1, [{ errors: [] }], 1)
304
+
305
+ assert.equal(ingestHealth(l, 'attached').reasons[0].reason, 'unknown')
306
+ })
@@ -0,0 +1,247 @@
1
+ /*
2
+ * ★ 인제스트 이력 — **재기동 뒤에도 추이가 이어진다.**
3
+ *
4
+ * ── 무엇이 미완이었나 (2026-08-20) ──────────────────────────────────────────
5
+ * 닫힌 창을 표로 남기기 시작했는데 **읽는 코드가 없었다.** 21행이 쌓여 있고 아무도 보지 않았다 —
6
+ * 이 프로젝트가 결함으로 보는 상태다(「필드만 만들고 아무도 읽지 않는다」). 표를 만든 이유가
7
+ * 「메모리 장부는 재기동에 사라지고 6시간까지인데 추이는 그보다 오래 봐야 한다」였으니 목적이
8
+ * 절반만 서 있었다.
9
+ *
10
+ * ── 지키는 것 ───────────────────────────────────────────────────────────────
11
+ * ① 표와 메모리를 이어 붙이되 **겹친 창을 두 번 내지 않는다** — 같은 구간이 두 번 그려지면 추이가
12
+ * 거짓이 된다. 겹치면 메모리 쪽을 쓴다(같은 사실의 원본이다).
13
+ * ② 어디서부터 메모리인지 밝힌다(`liveFrom`) — 화면이 근거를 말할 수 있게. 재기동 직후에는 `null`
14
+ * 이고, 그때 보이는 것은 전부 표에서 온 것이다.
15
+ * ③ **조용히 자르지 않는다** — 상한에서 밀려난 창 수와, 물은 기간이 잘렸다는 사실을 함께 낸다.
16
+ * ④ 남의 도메인·남의 트윈의 창을 집지 않는다.
17
+ * ⑤ 제시가 0 인 창의 통과율은 `null` 이다 — 빈 창은 완벽한 창이 아니다.
18
+ */
19
+ import { test, before, after } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+ import { createRequire } from 'node:module'
22
+ import type { DataSource } from 'typeorm'
23
+
24
+ const req = createRequire(import.meta.url)
25
+ const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
26
+ const { entities: authEntities } = req('@things-factory/auth-base')
27
+ const { entities: twinEntities } = req('../dist-server/service/index.js')
28
+ const { TwinEngine } = req('../dist-server/engine/index.js')
29
+ const { INGEST_WINDOW_MS } = req('../dist-server/engine/ingest-health.js')
30
+ const { TwinIngestWindow } = req('../dist-server/service/twin-ingest-window/twin-ingest-window.js')
31
+ const { TwinIngestWindowQuery, HISTORY_WINDOW_LIMIT, HISTORY_HOURS_MAX } = req(
32
+ '../dist-server/service/twin-ingest-window/twin-ingest-window-query.js'
33
+ )
34
+ const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js')
35
+ const { DataSource: TypeOrmDataSource } = req('typeorm')
36
+
37
+ let ds: DataSource
38
+ let domainId: string
39
+ let otherDomainId: string
40
+
41
+ const TWIN = 'hist-twin'
42
+ const HOUR = 3_600_000
43
+
44
+ const resolver = new TwinIngestWindowQuery()
45
+ const ask = (instanceId: string, hours?: number, domain = domainId) =>
46
+ resolver.twinIngestHistory(instanceId, hours, { state: { domain: { id: domain } } } as any)
47
+
48
+ /** 표에 닫힌 창 하나 — 시각은 「몇 시간 전」으로 준다. */
49
+ async function storedWindow(instanceId: string, hoursAgo: number, offered: number, rejected = 0, domain = domainId) {
50
+ const closed = new Date(Date.now() - hoursAgo * HOUR)
51
+ const opened = new Date(closed.getTime() - 600_000)
52
+ const repo = ds.getRepository(TwinIngestWindow)
53
+ await repo.save(
54
+ repo.create({
55
+ domain: { id: domain } as any,
56
+ instanceId,
57
+ openedAt: opened,
58
+ closedAt: closed,
59
+ offered,
60
+ rejected,
61
+ undelivered: 0,
62
+ journaledRows: 0,
63
+ journalFlushes: 0,
64
+ ...(offered > 0 ? { acceptedRatio: (offered - rejected) / offered } : {})
65
+ } as any)
66
+ )
67
+ return opened.toISOString()
68
+ }
69
+
70
+ async function instance(instanceId: string, domain = domainId) {
71
+ const repo = ds.getRepository(TwinInstance)
72
+ await repo.save(
73
+ repo.create({
74
+ domain: { id: domain } as any,
75
+ instanceId,
76
+ spaceId: 'hist-space',
77
+ kind: 'wms',
78
+ status: 'stopped',
79
+ restartPolicy: 'reset',
80
+ purpose: 'operational'
81
+ } as any)
82
+ )
83
+ }
84
+
85
+ before(async () => {
86
+ ds = new TypeOrmDataSource({
87
+ type: 'sqlite',
88
+ database: ':memory:',
89
+ synchronize: true,
90
+ dropSchema: true,
91
+ namingStrategy: new NamingStrategy(),
92
+ entities: [...shellEntities, ...authEntities, ...twinEntities],
93
+ logging: false
94
+ })
95
+ await ds.initialize()
96
+ addDataSource('default', ds)
97
+
98
+ domainId = (await ds.getRepository(Domain).save({ name: 'hist', subdomain: 'hist' } as any)).id
99
+ otherDomainId = (await ds.getRepository(Domain).save({ name: 'hist-other', subdomain: 'hist-other' } as any)).id
100
+ await instance(TWIN)
101
+ })
102
+
103
+ after(async () => {
104
+ await ds?.destroy()
105
+ })
106
+
107
+ test('표에 남은 창을 읽어 온다 — 재기동으로 메모리가 비어도 추이가 있다', async () => {
108
+ await storedWindow(TWIN, 5, 100, 4)
109
+ await storedWindow(TWIN, 3, 200, 0)
110
+
111
+ const r = await ask(TWIN)
112
+ assert.equal(r.windows.length, 2)
113
+ assert.equal(r.storedCount, 2)
114
+ assert.equal(r.liveCount, 0, '메모리에는 닫힌 창이 없다(이 트윈은 이 프로세스에서 받은 적이 없다)')
115
+ assert.equal(r.liveFrom, null, '그래서 「여기서부터 메모리」가 없다 — 0 이나 지금으로 채우지 않는다')
116
+ })
117
+
118
+ test('오래된 것이 앞, 최신이 뒤 — 추이는 시간순이어야 그림이 된다', async () => {
119
+ const r = await ask(TWIN)
120
+ const times = r.windows.map((w: any) => Date.parse(w.from))
121
+ assert.deepEqual(times, [...times].sort((a, b) => a - b), '오름차순')
122
+ })
123
+
124
+ test('통과는 트윈에 닿은 것만 — 표의 통과율을 그대로 쓴다', async () => {
125
+ const r = await ask(TWIN)
126
+ const w = r.windows.find((x: any) => x.offered === 100)
127
+ assert.equal(w.rejected, 4)
128
+ assert.equal(w.accepted, 96)
129
+ assert.equal(w.acceptedRatio, 0.96)
130
+ })
131
+
132
+ test('제시가 0 인 창의 통과율은 「없음」이다 — 빈 창은 완벽한 창이 아니다', async () => {
133
+ await storedWindow(TWIN, 2, 0, 0)
134
+
135
+ const r = await ask(TWIN)
136
+ const empty = r.windows.find((x: any) => x.offered === 0)
137
+ assert.equal(empty.acceptedRatio, null)
138
+ })
139
+
140
+ test('물은 기간 밖의 창은 오지 않는다', async () => {
141
+ await storedWindow(TWIN, 40, 999, 0) // 40시간 전
142
+
143
+ const r = await ask(TWIN, 24)
144
+ assert.ok(
145
+ !r.windows.some((w: any) => w.offered === 999),
146
+ '24시간을 물었으면 40시간 전 창은 안 온다'
147
+ )
148
+
149
+ const wide = await ask(TWIN, 48)
150
+ assert.ok(
151
+ wide.windows.some((w: any) => w.offered === 999),
152
+ '48시간을 물으면 온다'
153
+ )
154
+ })
155
+
156
+ test('기간 상한을 넘겨 물으면 자르고, 자른 사실을 낸다', async () => {
157
+ const r = await ask(TWIN, HISTORY_HOURS_MAX + 100)
158
+
159
+ assert.equal(r.hours, HISTORY_HOURS_MAX, '상한까지만 본다')
160
+ assert.equal(r.hoursClamped, HISTORY_HOURS_MAX + 100, '얼마를 물었는지 밝힌다 — 조용히 자르지 않는다')
161
+ })
162
+
163
+ test('상한 안에서는 잘렸다고 말하지 않는다 — 없는 문제를 만들지 않는다', async () => {
164
+ const r = await ask(TWIN, 24)
165
+ assert.equal(r.hoursClamped, undefined)
166
+ assert.equal(r.storedDropped, 0)
167
+ })
168
+
169
+ test('창 수 상한에서 밀려난 수를 센다', async () => {
170
+ const many = 'hist-many'
171
+ await instance(many)
172
+ /* 상한 + 5개를 심는다. 10분 간격이므로 상한을 넘기려면 그만큼의 창이 필요하다. */
173
+ for (let i = 0; i < HISTORY_WINDOW_LIMIT + 5; i++) await storedWindow(many, 1 + i * 0.01, 10, 0)
174
+
175
+ const r = await ask(many, HISTORY_HOURS_MAX)
176
+ assert.equal(r.windows.length, HISTORY_WINDOW_LIMIT, '상한까지만 낸다')
177
+ assert.equal(r.storedDropped, 5, '밀려난 수를 말한다')
178
+ })
179
+
180
+ test('남의 도메인 창을 집지 않는다', async () => {
181
+ await instance('hist-shared', otherDomainId)
182
+ await storedWindow('hist-shared', 1, 777, 0, otherDomainId)
183
+
184
+ /* 같은 instanceId 를 내 도메인에서 물어도 남의 창이 오면 안 된다. */
185
+ await instance('hist-shared')
186
+ const mine = await ask('hist-shared')
187
+ assert.ok(
188
+ !mine.windows.some((w: any) => w.offered === 777),
189
+ '남의 도메인 창이 새지 않는다'
190
+ )
191
+ })
192
+
193
+ test('내 도메인에 없는 트윈은 「없음」이다 — 남의 것이라 말하지 않는다', async () => {
194
+ assert.equal(await ask('does-not-exist'), null)
195
+ })
196
+
197
+ test('겹친 창을 두 번 내지 않는다 — 같은 구간이 두 번 그려지면 추이가 거짓이 된다', async () => {
198
+ /*
199
+ * 프로세스가 살아 있는 동안 닫힌 창은 **표와 메모리에 둘 다** 있다(영속 훅이 창이 닫힐 때 쓴다).
200
+ * 정리하지 않으면 그 구간이 추이에 두 번 들어가 유입이 두 배로 보인다.
201
+ */
202
+ const twin = 'hist-overlap'
203
+ await instance(twin)
204
+
205
+ /* 메모리에 닫힌 창 하나를 만든다 — 창은 다음 유입이 있을 때 닫힌다. */
206
+ const t0 = Date.now() - Math.floor(INGEST_WINDOW_MS * 1.4)
207
+ TwinEngine.recordIngestResult(domainId, twin, 50, [], t0)
208
+ TwinEngine.recordIngestResult(domainId, twin, 1, [], t0 + INGEST_WINDOW_MS + 1)
209
+
210
+ const live = TwinEngine.ingestHealthOf(domainId, twin).trend
211
+ assert.equal(live.length, 1, '메모리에 닫힌 창 하나')
212
+
213
+ /* 같은 창을 표에도 심는다(영속 훅이 했을 일). 값은 일부러 다르게 두어 어느 쪽이 이겼는지 본다. */
214
+ const repo = ds.getRepository(TwinIngestWindow)
215
+ await repo.save(
216
+ repo.create({
217
+ domain: { id: domainId } as any,
218
+ instanceId: twin,
219
+ openedAt: new Date(Date.parse(live[0].from)),
220
+ closedAt: new Date(Date.parse(live[0].to)),
221
+ offered: 999,
222
+ rejected: 0,
223
+ undelivered: 0,
224
+ journaledRows: 0,
225
+ journalFlushes: 0,
226
+ acceptedRatio: 1
227
+ } as any)
228
+ )
229
+
230
+ const r = await ask(twin)
231
+ const sameStart = r.windows.filter((w: any) => Date.parse(w.from) === Date.parse(live[0].from))
232
+ assert.equal(sameStart.length, 1, '한 번만 낸다')
233
+ assert.equal(sameStart[0].source, 'live', '겹치면 메모리 쪽 — 같은 사실의 원본이다')
234
+ assert.equal(sameStart[0].offered, 50, '표의 999 가 아니라 메모리의 50')
235
+ assert.equal(r.liveFrom, live[0].from, '어디서부터 메모리인지 밝힌다')
236
+ })
237
+
238
+ test('겹치지 않는 표의 창은 메모리 앞에 이어 붙는다', async () => {
239
+ const twin = 'hist-overlap'
240
+ /* 메모리 창보다 오래된 것 — 이건 표에서만 온다. */
241
+ await storedWindow(twin, 6, 30, 1)
242
+
243
+ const r = await ask(twin)
244
+ assert.equal(r.windows.length, 2, '표 하나 + 메모리 하나')
245
+ assert.equal(r.windows[0].source, 'stored', '오래된 것이 앞')
246
+ assert.equal(r.windows[1].source, 'live')
247
+ })
@@ -0,0 +1,103 @@
1
+ /*
2
+ * ★ 연결 적합성 하네스 — **절반만 돌린 스위트가 초록을 주장하지 못하게.**
3
+ *
4
+ * 지키는 것:
5
+ * ① 돌리지 않은 프로브는 **「모른다」**로 접힌다 → 판정이 그 단계를 달성으로 만들지 않는다.
6
+ * ② **실패한 프로브의 수를 0 으로 읽지 않는다.** 「0건이었다」와 「세지 못했다」는 다른 사실이다.
7
+ * ③ **쓰기 프로브는 동의 없이 계획에 들어오지 않는다** — 남의 운영 시스템에 명령을 넣는 일이다.
8
+ * ④ **게이팅 실패 뒤는 돌리지 않는다** — 빨간 칸을 늘리는 것은 원인을 알려 주지 않는다.
9
+ */
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { PROBE_SEQUENCE, foldProbeResults, plannedProbes, type ProbeResult } from '../server/engine/integration-probes.ts'
14
+ import { assessStagePath, forbiddenClaims } from '../server/engine/stage-path.ts'
15
+
16
+ const pass = (id: any, count?: number, coverage?: { declared: number; total: number }): ProbeResult =>
17
+ ({ id, ran: true, passed: true, ...(count !== undefined ? { count } : {}), ...(coverage ? { coverage } : {}) })
18
+
19
+ /** 읽기 프로브 전부 통과 — 반출까지 관측된 상태. */
20
+ const readOnlyAllPass = (): ProbeResult[] => [
21
+ pass('connect'), pass('discover', 1), pass('master', 12), pass('master-warnings', 3), pass('items', 340),
22
+ pass('live-ingest', 1200), pass('live-removal', 17),
23
+ pass('declarations', undefined, { declared: 6, total: 8 }), pass('forecast'),
24
+ pass('actuate-declared')
25
+ ]
26
+
27
+ test('시퀀스는 읽기부터 시작하고 쓰기 프로브는 하나뿐이다', () => {
28
+ assert.equal(PROBE_SEQUENCE[0].id, 'connect')
29
+ assert.deepEqual(PROBE_SEQUENCE.filter(p => p.writes).map(p => p.id), ['actuate-effect'])
30
+ })
31
+
32
+ test('아무것도 돌리지 않았으면 첫 칸부터 「모른다」다 — 실패로 접지 않는다', () => {
33
+ const p = assessStagePath(foldProbeResults([]))
34
+ assert.equal(p.reached, null)
35
+ assert.deepEqual(p.next?.stage, 'structure')
36
+ assert.ok(p.stages[0].blockedBy.includes('no-locations'))
37
+ assert.ok(p.stages[0].unmeasured.includes('master-warnings'), '세지 않은 것은 모르는 것이다')
38
+ })
39
+
40
+ test('읽기만 전부 통과하면 예측까지 도달하고 폐루프는 「검증되지 않음」으로 남는다', () => {
41
+ const p = assessStagePath(foldProbeResults(readOnlyAllPass()))
42
+ assert.equal(p.reached, 'forecast')
43
+ assert.deepEqual(p.stages[3].unmeasured, ['correlated-effect'])
44
+ assert.deepEqual(p.stages[3].blockedBy, [], '능력은 선언∩구현이므로 막힌 것이 아니라 안 돌린 것이다')
45
+ assert.deepEqual(forbiddenClaims(p), ['claim.automatic-control'])
46
+ })
47
+
48
+ test('쓰기 프로브를 돌려 효과가 관측되면 폐루프가 닫힌다', () => {
49
+ const p = assessStagePath(foldProbeResults([...readOnlyAllPass(), pass('actuate-effect', 2)]))
50
+ assert.equal(p.reached, 'closed-loop')
51
+ assert.deepEqual(forbiddenClaims(p), [])
52
+ })
53
+
54
+ test('측정하지 못한 프로브는 수를 들지 않는다 — 0 을 지어내지 않는다', () => {
55
+ /*
56
+ * 규칙은 「실패면 모른다」가 아니다 — **센 값은 판정과 무관한 사실**이다(0건도 사실이다).
57
+ * 측정 자체를 못 한 프로브(타임아웃 등)는 `count` 를 **비운다**. 0 을 채우면 「세어 보니 없었다」로
58
+ * 읽히고, 그건 스냅샷 피드를 delta 로 오인하게 만드는 정반대의 결론을 낳는다.
59
+ */
60
+ const rs: ProbeResult[] = [...readOnlyAllPass().filter(r => r.id !== 'live-removal'),
61
+ { id: 'live-removal', ran: true, passed: false, detail: 'timeout' }]
62
+ const e = foldProbeResults(rs)
63
+ assert.equal(e.removalsObserved, null, '측정하지 못했으면 모른다')
64
+ assert.deepEqual(assessStagePath(e).stages[1].unmeasured, ['removals'])
65
+ })
66
+
67
+ test('세어서 0 을 얻은 것은 사실이다 — 모른다로 접지 않는다', () => {
68
+ const rs: ProbeResult[] = [...readOnlyAllPass().filter(r => r.id !== 'live-removal'),
69
+ { id: 'live-removal', ran: true, passed: false, count: 0 }]
70
+ const e = foldProbeResults(rs)
71
+ assert.equal(e.removalsObserved, 0)
72
+ assert.deepEqual(assessStagePath(e).stages[1].blockedBy, ['no-removal-observed'])
73
+ })
74
+
75
+ test('반출을 세었는데 0 이면 라이브가 막힌다 — 스냅샷 되풀어 읽기', () => {
76
+ const rs: ProbeResult[] = [...readOnlyAllPass().filter(r => r.id !== 'live-removal'), pass('live-removal', 0)]
77
+ const p = assessStagePath(foldProbeResults(rs))
78
+ assert.deepEqual(p.stages[1].blockedBy, ['no-removal-observed'])
79
+ assert.equal(p.reached, 'structure')
80
+ assert.ok(forbiddenClaims(p).includes('claim.realtime'))
81
+ })
82
+
83
+ test('쓰기 프로브는 동의 없이 계획에 들어오지 않는다', () => {
84
+ assert.ok(!plannedProbes([]).includes('actuate-effect'))
85
+ assert.ok(plannedProbes([], { allowWrites: true }).includes('actuate-effect'))
86
+ })
87
+
88
+ test('게이팅 프로브가 실패하면 그 뒤는 계획하지 않는다', () => {
89
+ const planned = plannedProbes([{ id: 'connect', ran: true, passed: false, detail: '401' }])
90
+ assert.deepEqual(planned, [], '붙지 못했으면 뒤를 두드릴 이유가 없다')
91
+ })
92
+
93
+ test('통과한 프로브는 다시 계획하지 않고, 남은 것만 계획한다', () => {
94
+ const planned = plannedProbes([pass('connect'), pass('discover', 1)])
95
+ assert.equal(planned[0], 'master')
96
+ assert.ok(!planned.includes('connect'))
97
+ })
98
+
99
+ test('게이팅이 아닌 프로브의 실패는 뒤를 멈추지 않는다', () => {
100
+ const planned = plannedProbes([pass('connect'), pass('discover', 1), pass('master', 12),
101
+ { id: 'master-warnings', ran: true, passed: false, detail: 'not-counted' }])
102
+ assert.ok(planned.includes('items'), '못 옮긴 값을 세지 못한 것이 구조 확인을 멈추지는 않는다')
103
+ })