@things-factory/headless-twin 10.0.11 → 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 (369) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +81 -3
  2. package/dist-server/engine/canonical-ingest.js +89 -10
  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/declared-stimulus.d.ts +43 -0
  7. package/dist-server/engine/declared-stimulus.js +57 -0
  8. package/dist-server/engine/declared-stimulus.js.map +1 -0
  9. package/dist-server/engine/index.d.ts +5 -0
  10. package/dist-server/engine/index.js +6 -0
  11. package/dist-server/engine/index.js.map +1 -1
  12. package/dist-server/engine/ingest-health.d.ts +335 -0
  13. package/dist-server/engine/ingest-health.js +434 -0
  14. package/dist-server/engine/ingest-health.js.map +1 -0
  15. package/dist-server/engine/integration-coverage.d.ts +76 -0
  16. package/dist-server/engine/integration-coverage.js +73 -0
  17. package/dist-server/engine/integration-coverage.js.map +1 -0
  18. package/dist-server/engine/integration-probes.d.ts +65 -0
  19. package/dist-server/engine/integration-probes.js +100 -0
  20. package/dist-server/engine/integration-probes.js.map +1 -0
  21. package/dist-server/engine/integration-runner.d.ts +45 -0
  22. package/dist-server/engine/integration-runner.js +59 -0
  23. package/dist-server/engine/integration-runner.js.map +1 -0
  24. package/dist-server/engine/integration-target-profile.d.ts +57 -0
  25. package/dist-server/engine/integration-target-profile.js +79 -0
  26. package/dist-server/engine/integration-target-profile.js.map +1 -0
  27. package/dist-server/engine/kpi-fold.d.ts +39 -0
  28. package/dist-server/engine/kpi-fold.js +68 -5
  29. package/dist-server/engine/kpi-fold.js.map +1 -1
  30. package/dist-server/engine/kpi-query.d.ts +3 -3
  31. package/dist-server/engine/kpi-query.js +132 -18
  32. package/dist-server/engine/kpi-query.js.map +1 -1
  33. package/dist-server/engine/live-feed-registry.d.ts +1 -1
  34. package/dist-server/engine/live-feed-registry.js +3 -17
  35. package/dist-server/engine/live-feed-registry.js.map +1 -1
  36. package/dist-server/engine/load-meter.d.ts +1 -1
  37. package/dist-server/engine/load-meter.js +12 -3
  38. package/dist-server/engine/load-meter.js.map +1 -1
  39. package/dist-server/engine/local-declarations.d.ts +3 -6
  40. package/dist-server/engine/local-declarations.js +97 -16
  41. package/dist-server/engine/local-declarations.js.map +1 -1
  42. package/dist-server/engine/log.d.ts +18 -0
  43. package/dist-server/engine/log.js +80 -0
  44. package/dist-server/engine/log.js.map +1 -0
  45. package/dist-server/engine/loop-lag.d.ts +54 -0
  46. package/dist-server/engine/loop-lag.js +87 -0
  47. package/dist-server/engine/loop-lag.js.map +1 -0
  48. package/dist-server/engine/measured-yield.d.ts +42 -0
  49. package/dist-server/engine/measured-yield.js +75 -0
  50. package/dist-server/engine/measured-yield.js.map +1 -0
  51. package/dist-server/engine/model-gap.d.ts +108 -0
  52. package/dist-server/engine/model-gap.js +95 -0
  53. package/dist-server/engine/model-gap.js.map +1 -0
  54. package/dist-server/engine/operation-basis.d.ts +16 -0
  55. package/dist-server/engine/operation-basis.js +20 -2
  56. package/dist-server/engine/operation-basis.js.map +1 -1
  57. package/dist-server/engine/property-effects.js +17 -0
  58. package/dist-server/engine/property-effects.js.map +1 -1
  59. package/dist-server/engine/restart-policy.d.ts +13 -0
  60. package/dist-server/engine/restart-policy.js +52 -0
  61. package/dist-server/engine/restart-policy.js.map +1 -0
  62. package/dist-server/engine/runtime-key.js +1 -1
  63. package/dist-server/engine/runtime-key.js.map +1 -1
  64. package/dist-server/engine/spec-coverage.d.ts +8 -0
  65. package/dist-server/engine/spec-coverage.js +3 -1
  66. package/dist-server/engine/spec-coverage.js.map +1 -1
  67. package/dist-server/engine/stage-path.d.ts +83 -0
  68. package/dist-server/engine/stage-path.js +118 -0
  69. package/dist-server/engine/stage-path.js.map +1 -0
  70. package/dist-server/engine/twin-engine.d.ts +400 -31
  71. package/dist-server/engine/twin-engine.js +1198 -187
  72. package/dist-server/engine/twin-engine.js.map +1 -1
  73. package/dist-server/index.js +35 -3
  74. package/dist-server/index.js.map +1 -1
  75. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
  76. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
  77. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
  78. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
  79. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
  80. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
  81. package/dist-server/migrations/index.js +7 -1
  82. package/dist-server/migrations/index.js.map +1 -1
  83. package/dist-server/service/index.d.ts +5 -2
  84. package/dist-server/service/index.js +18 -7
  85. package/dist-server/service/index.js.map +1 -1
  86. package/dist-server/service/reference/control-routing.d.ts +14 -0
  87. package/dist-server/service/reference/control-routing.js +64 -0
  88. package/dist-server/service/reference/control-routing.js.map +1 -0
  89. package/dist-server/service/reference/discovery-result.d.ts +1 -1
  90. package/dist-server/service/reference/discovery-result.js +1 -1
  91. package/dist-server/service/reference/discovery-result.js.map +1 -1
  92. package/dist-server/service/reference/index.d.ts +5 -1
  93. package/dist-server/service/reference/index.js +8 -1
  94. package/dist-server/service/reference/index.js.map +1 -1
  95. package/dist-server/service/reference/reference-adapter.d.ts +251 -3
  96. package/dist-server/service/reference/reference-adapter.js +43 -1
  97. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  98. package/dist-server/service/reference/reference-assessment.d.ts +68 -0
  99. package/dist-server/service/reference/reference-assessment.js +136 -0
  100. package/dist-server/service/reference/reference-assessment.js.map +1 -0
  101. package/dist-server/service/reference/reference-live.d.ts +12 -1
  102. package/dist-server/service/reference/reference-live.js +119 -14
  103. package/dist-server/service/reference/reference-live.js.map +1 -1
  104. package/dist-server/service/reference/reference-master.d.ts +47 -1
  105. package/dist-server/service/reference/reference-master.js +34 -4
  106. package/dist-server/service/reference/reference-master.js.map +1 -1
  107. package/dist-server/service/reference/reference-probe.d.ts +92 -0
  108. package/dist-server/service/reference/reference-probe.js +186 -0
  109. package/dist-server/service/reference/reference-probe.js.map +1 -0
  110. package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
  111. package/dist-server/service/reference/reference-progress-subscription.js +94 -0
  112. package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
  113. package/dist-server/service/reference/reference-progress.d.ts +38 -0
  114. package/dist-server/service/reference/reference-progress.js +71 -0
  115. package/dist-server/service/reference/reference-progress.js.map +1 -0
  116. package/dist-server/service/reference/reference-resolver.d.ts +3 -3
  117. package/dist-server/service/reference/reference-resolver.js +242 -58
  118. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  119. package/dist-server/service/reference/twin-reference.d.ts +32 -0
  120. package/dist-server/service/reference/twin-reference.js +10 -0
  121. package/dist-server/service/reference/twin-reference.js.map +1 -1
  122. package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
  123. package/dist-server/service/twin-audit/command-audit.js +15 -1
  124. package/dist-server/service/twin-audit/command-audit.js.map +1 -1
  125. package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
  126. package/dist-server/service/twin-audit/twin-audit-event.js +10 -0
  127. package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
  128. package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
  129. package/dist-server/service/twin-control/twin-control-mutation.js +88 -36
  130. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  131. package/dist-server/service/twin-event/journal-count.d.ts +21 -0
  132. package/dist-server/service/twin-event/journal-count.js +26 -0
  133. package/dist-server/service/twin-event/journal-count.js.map +1 -0
  134. package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
  135. package/dist-server/service/twin-event/twin-event-keys.js +15 -18
  136. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  137. package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
  138. package/dist-server/service/twin-event/twin-event-type.js +18 -1
  139. package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
  140. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  141. package/dist-server/service/twin-event/twin-event.js +9 -1
  142. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  143. package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
  144. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  145. package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
  146. package/dist-server/service/twin-ingest-window/index.js +10 -0
  147. package/dist-server/service/twin-ingest-window/index.js.map +1 -0
  148. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
  149. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
  150. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
  151. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
  152. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
  153. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
  154. package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
  155. package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
  156. package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
  157. package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
  158. package/dist-server/service/twin-instance/twin-instance.js +2 -2
  159. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  160. package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
  161. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  162. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
  163. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +74 -3
  164. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  165. package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
  166. package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
  167. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  168. package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
  169. package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
  170. package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
  171. package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
  172. package/dist-server/service/twin-model/epcis-coverage.js +19 -7
  173. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  174. package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
  175. package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
  176. package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
  177. package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
  178. package/dist-server/service/twin-model/isa95-coverage.js +23 -9
  179. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  180. package/dist-server/service/twin-model/item-ref.d.ts +24 -0
  181. package/dist-server/service/twin-model/item-ref.js +88 -0
  182. package/dist-server/service/twin-model/item-ref.js.map +1 -0
  183. package/dist-server/service/twin-model/name-index.d.ts +36 -0
  184. package/dist-server/service/twin-model/name-index.js +116 -0
  185. package/dist-server/service/twin-model/name-index.js.map +1 -0
  186. package/dist-server/service/twin-model/project-structure.js +1 -1
  187. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  188. package/dist-server/service/twin-model/status-tally.d.ts +9 -0
  189. package/dist-server/service/twin-model/status-tally.js +37 -0
  190. package/dist-server/service/twin-model/status-tally.js.map +1 -0
  191. package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
  192. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  193. package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
  194. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  195. package/dist-server/service/twin-model/twin-model-query.js +157 -5
  196. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  197. package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
  198. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  199. package/dist-server/service/twin-readiness/index.d.ts +2 -0
  200. package/dist-server/service/twin-readiness/index.js +6 -0
  201. package/dist-server/service/twin-readiness/index.js.map +1 -0
  202. package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
  203. package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
  204. package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
  205. package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
  206. package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
  207. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  208. package/dist-shared/entity-delta.d.ts +23 -3
  209. package/dist-shared/entity-delta.js +12 -8
  210. package/dist-shared/entity-delta.js.map +1 -1
  211. package/dist-shared/kpi-broadcast.js +1 -1
  212. package/dist-shared/kpi-broadcast.js.map +1 -1
  213. package/dist-shared/touched-items.d.ts +7 -0
  214. package/dist-shared/touched-items.js +80 -0
  215. package/dist-shared/touched-items.js.map +1 -0
  216. package/package.json +7 -7
  217. package/server/engine/canonical-ingest.ts +164 -15
  218. package/server/engine/command-routing.ts +1 -1
  219. package/server/engine/declared-stimulus.ts +66 -0
  220. package/server/engine/index.ts +6 -0
  221. package/server/engine/ingest-health.ts +704 -0
  222. package/server/engine/integration-coverage.ts +147 -0
  223. package/server/engine/integration-probes.ts +144 -0
  224. package/server/engine/integration-runner.ts +95 -0
  225. package/server/engine/integration-target-profile.ts +103 -0
  226. package/server/engine/kpi-fold.ts +101 -5
  227. package/server/engine/kpi-query.ts +137 -23
  228. package/server/engine/live-feed-registry.ts +4 -3
  229. package/server/engine/load-meter.ts +12 -3
  230. package/server/engine/local-declarations.ts +97 -19
  231. package/server/engine/log.ts +72 -0
  232. package/server/engine/loop-lag.ts +120 -0
  233. package/server/engine/measured-yield.ts +89 -0
  234. package/server/engine/model-gap.ts +168 -0
  235. package/server/engine/operation-basis.ts +33 -2
  236. package/server/engine/property-effects.ts +17 -0
  237. package/server/engine/restart-policy.ts +55 -0
  238. package/server/engine/runtime-key.ts +1 -1
  239. package/server/engine/spec-coverage.ts +23 -3
  240. package/server/engine/stage-path.ts +172 -0
  241. package/server/engine/twin-engine.ts +1318 -186
  242. package/server/index.ts +36 -3
  243. package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
  244. package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
  245. package/server/migrations/index.ts +7 -1
  246. package/server/service/index.ts +11 -0
  247. package/server/service/reference/control-routing.ts +62 -0
  248. package/server/service/reference/discovery-result.ts +1 -1
  249. package/server/service/reference/index.ts +7 -1
  250. package/server/service/reference/reference-adapter.ts +275 -5
  251. package/server/service/reference/reference-assessment.ts +215 -0
  252. package/server/service/reference/reference-live.ts +126 -14
  253. package/server/service/reference/reference-master.ts +64 -4
  254. package/server/service/reference/reference-probe.ts +264 -0
  255. package/server/service/reference/reference-progress-subscription.ts +73 -0
  256. package/server/service/reference/reference-progress.ts +95 -0
  257. package/server/service/reference/reference-resolver.ts +246 -19
  258. package/server/service/reference/twin-reference.ts +39 -1
  259. package/server/service/twin-audit/command-audit.ts +34 -1
  260. package/server/service/twin-audit/twin-audit-event.ts +20 -0
  261. package/server/service/twin-control/twin-control-mutation.ts +78 -30
  262. package/server/service/twin-event/journal-count.ts +53 -0
  263. package/server/service/twin-event/twin-event-keys.ts +16 -1
  264. package/server/service/twin-event/twin-event-type.ts +26 -2
  265. package/server/service/twin-event/twin-event.ts +7 -0
  266. package/server/service/twin-forecast/twin-forecast-query.ts +2 -1
  267. package/server/service/twin-ingest-window/index.ts +7 -0
  268. package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
  269. package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
  270. package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
  271. package/server/service/twin-instance/twin-instance.ts +16 -10
  272. package/server/service/twin-journal/twin-journal-query.ts +59 -5
  273. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +79 -4
  274. package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
  275. package/server/service/twin-model/axis-journal-evidence.ts +57 -0
  276. package/server/service/twin-model/epcis-coverage.ts +2 -8
  277. package/server/service/twin-model/iec61850-coverage.ts +2 -8
  278. package/server/service/twin-model/isa95-coverage.ts +22 -9
  279. package/server/service/twin-model/item-ref.ts +80 -0
  280. package/server/service/twin-model/name-index.ts +94 -0
  281. package/server/service/twin-model/project-structure.ts +1 -1
  282. package/server/service/twin-model/status-tally.ts +37 -0
  283. package/server/service/twin-model/twin-lineage-query.ts +38 -9
  284. package/server/service/twin-model/twin-model-item-query.ts +26 -27
  285. package/server/service/twin-model/twin-model-query.ts +156 -5
  286. package/server/service/twin-model/twin-model-tree-query.ts +7 -0
  287. package/server/service/twin-readiness/index.ts +3 -0
  288. package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
  289. package/server/service/twin-space/twin-space-resolver.ts +5 -4
  290. package/shared/entity-delta.ts +31 -7
  291. package/shared/kpi-broadcast.ts +1 -1
  292. package/shared/touched-items.ts +73 -0
  293. package/test/adopt-structure-live.test.ts +7 -7
  294. package/test/axis-journal-evidence.test.ts +71 -0
  295. package/test/axis-read.test.ts +101 -1
  296. package/test/boot-resume.test.ts +93 -27
  297. package/test/broadcast-cost-baseline.test.ts +200 -0
  298. package/test/broadcast-period.test.ts +58 -0
  299. package/test/canonical-ingest-vocabularies.test.ts +36 -1
  300. package/test/canonical-quantity-door.test.ts +61 -0
  301. package/test/command-routing.test.ts +1 -1
  302. package/test/control-capability.test.ts +103 -0
  303. package/test/declared-location-types.test.ts +89 -0
  304. package/test/declared-stimulus.test.ts +88 -0
  305. package/test/discovery-result.test.ts +1 -1
  306. package/test/duration-estimators.test.ts +1 -1
  307. package/test/entity-delta.test.ts +2 -2
  308. package/test/ingest-bench.test.ts +3 -3
  309. package/test/ingest-health-engine.test.ts +248 -0
  310. package/test/ingest-health-wiring.test.ts +119 -0
  311. package/test/ingest-health.test.ts +306 -0
  312. package/test/ingest-history.test.ts +247 -0
  313. package/test/ingest-running-guard.test.ts +5 -5
  314. package/test/instance-cache-lifecycle.test.ts +1 -1
  315. package/test/integration-probes.test.ts +103 -0
  316. package/test/integration-runner.test.ts +95 -0
  317. package/test/item-ref.test.ts +78 -0
  318. package/test/journal-read-discipline.test.ts +177 -0
  319. package/test/journal-retention.test.ts +133 -0
  320. package/test/journal-sort-axis.test.ts +142 -0
  321. package/test/journal-write-door.test.ts +110 -0
  322. package/test/journal-write-trend.test.ts +115 -0
  323. package/test/kernel-kind-guard.test.ts +4 -4
  324. package/test/kpi-baseline-db.test.ts +1 -1
  325. package/test/kpi-fold.test.ts +90 -3
  326. package/test/kpi-query-bench.test.ts +3 -3
  327. package/test/lineage-survives-restart.test.ts +20 -4
  328. package/test/live-cursor-wiring.test.ts +87 -0
  329. package/test/live-feed-registry.test.ts +15 -7
  330. package/test/live-kernel-facts.test.ts +7 -7
  331. package/test/live-mirror-parity.test.ts +6 -0
  332. package/test/load-meter.test.ts +29 -15
  333. package/test/local-declarations.test.ts +109 -2
  334. package/test/log-stamp.test.ts +59 -0
  335. package/test/loop-lag.test.ts +82 -0
  336. package/test/measured-yield.test.ts +90 -0
  337. package/test/model-gap.test.ts +153 -0
  338. package/test/oee-accumulator.test.ts +4 -0
  339. package/test/operation-basis.test.ts +28 -1
  340. package/test/operational-vocabulary.test.ts +108 -0
  341. package/test/operations-capability-db.test.ts +4 -4
  342. package/test/project-structure-db.test.ts +1 -1
  343. package/test/projection-reaches-screen.test.ts +1 -1
  344. package/test/property-effects.test.ts +28 -0
  345. package/test/read-failure-visible.test.ts +145 -0
  346. package/test/reference-grounding.test.ts +70 -0
  347. package/test/resolve-ts-siblings.mjs +52 -0
  348. package/test/restart-policy.test.ts +111 -0
  349. package/test/resync-origin-site.test.ts +7 -1
  350. package/test/revision-axis.test.ts +93 -0
  351. package/test/runtime-key.test.ts +2 -2
  352. package/test/scale-twin-bench.test.ts +2 -2
  353. package/test/source-outcome-audit.test.ts +104 -0
  354. package/test/spec-coverage.test.ts +1 -1
  355. package/test/stage-path.test.ts +95 -0
  356. package/test/standard-coverage.test.ts +15 -3
  357. package/test/status-tally.test.ts +55 -0
  358. package/test/structure-revision-db.test.ts +31 -27
  359. package/test/tenant-registry-db.test.ts +3 -3
  360. package/test/time-range.test.ts +152 -0
  361. package/test/touched-items.test.ts +61 -0
  362. package/test/twin-event-keys.test.ts +10 -2
  363. package/test/twin-model-item-db.test.ts +5 -3
  364. package/test/twin-model-tree-db.test.ts +7 -7
  365. package/test/twin-origin-resync.test.ts +4 -4
  366. package/test/warm-start-seam.test.ts +4 -0
  367. package/test/yield-loop.test.ts +199 -0
  368. package/tsconfig.shared.tsbuildinfo +1 -1
  369. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,119 @@
1
+ /*
2
+ * 유입 경로가 **모두 같은 장부**에 적는다 — 소스 규율 검사.
3
+ *
4
+ * ── 왜 기계가 지켜야 하나 ───────────────────────────────────────────────────
5
+ * 피드를 여는 경로가 둘이다(커넥터 `openLiveFeed` · 시나리오 `twin-ingest` 태스크). 한쪽만 장부에
6
+ * 적으면 그 경로로 먹이는 트윈이 **「한 번도 받지 않은 것」**으로 보인다. 그 판정은 `unknown` 이고,
7
+ * `unknown` 은 화면에서 「정상」과 구별되지 않는다 — 즉 **조용히 틀린 답**이 된다.
8
+ *
9
+ * 세 번째 경로가 생길 때 사람이 이 사실을 기억할 것이라고 믿지 않는다. 인제스트를 부르는 자리를
10
+ * 소스에서 찾아 장부 기록이 함께 있는지 본다.
11
+ */
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ /** 훑는 곳 — 이 저장소에서 인제스트를 부를 수 있는 층 둘. */
18
+ const ROOTS = [
19
+ new URL('../server', import.meta.url).pathname,
20
+ new URL('../../operato-twin/server', import.meta.url).pathname
21
+ ]
22
+
23
+ /** 인제스트를 여는 함수 이름. 이 이름이 나오는 자리가 곧 유입 경로다. */
24
+ const INGEST_CALL = 'ingestCanonicalRecords('
25
+
26
+ /** 장부에 적는 창구. */
27
+ const LEDGER_CALL = 'recordIngestResult('
28
+
29
+ /**
30
+ * 예외 — 인제스트를 **부르지 않고 다시 내보내기만** 하는 자리.
31
+ *
32
+ * 늘리려면 이유를 함께 적는다. 「부르는데 안 적는다」는 예외가 될 수 없다 — 그게 이 검사가 막는 것이다.
33
+ */
34
+ const REEXPORT_ONLY = ['board/tasks/twin-ingest-core.ts', 'engine/index.ts', 'engine/canonical-ingest.ts']
35
+
36
+ function sources(dir: string): string[] {
37
+ let entries: string[]
38
+ try {
39
+ entries = readdirSync(dir)
40
+ } catch {
41
+ return []
42
+ }
43
+ return entries.flatMap(f => {
44
+ const p = join(dir, f)
45
+ return statSync(p).isDirectory() ? sources(p) : p.endsWith('.ts') && !p.endsWith('.d.ts') ? [p] : []
46
+ })
47
+ }
48
+
49
+ function ingestCallSites(): { file: string; src: string }[] {
50
+ const out: { file: string; src: string }[] = []
51
+ for (const root of ROOTS) {
52
+ for (const f of sources(root)) {
53
+ const src = readFileSync(f, 'utf-8')
54
+ if (!src.includes(INGEST_CALL)) continue
55
+ const rel = f.slice(root.length + 1)
56
+ if (REEXPORT_ONLY.some(x => rel.endsWith(x))) continue
57
+ out.push({ file: rel, src })
58
+ }
59
+ }
60
+ return out
61
+ }
62
+
63
+ test('인제스트를 부르는 곳은 모두 장부에 적는다 — 한 경로만 빠지면 그 트윈은 「모름」으로 보인다', () => {
64
+ const missing = ingestCallSites()
65
+ .filter(s => !s.src.includes(LEDGER_CALL))
66
+ .map(s => s.file)
67
+
68
+ assert.deepEqual(
69
+ missing,
70
+ [],
71
+ `장부에 적지 않는 유입 경로 ${missing.length}곳:\n ${missing.join('\n ')}\n\n` +
72
+ `그 경로로 먹이는 트윈은 twinIngestHealth 에서 「한 번도 받지 않음」(unknown)으로 보이고, ` +
73
+ `화면에서 정상과 구별되지 않는다. TwinEngine.recordIngestResult(도메인, 인스턴스, 제시수, 거부) 를 부른다.`
74
+ )
75
+ })
76
+
77
+ test('검사가 실제로 경로를 보고 있다 — 대상이 사라지면 초록이 거짓이 된다', () => {
78
+ const sites = ingestCallSites().map(s => s.file)
79
+
80
+ assert.ok(
81
+ sites.length >= 2,
82
+ `유입 경로를 ${sites.length}곳 찾았다 — 둘(커넥터·시나리오)보다 적으면 호출 형태가 바뀐 것이다. ` +
83
+ `이 검사의 패턴을 함께 옮긴다. 찾은 것: ${sites.join(', ') || '없음'}`
84
+ )
85
+ })
86
+
87
+ test('유입 경로는 커널이 실제로 받은 수를 견준다 — 버려진 것을 통과로 세지 않게', () => {
88
+ /*
89
+ * 겪은 일: 트윈이 멈춘 뒤에도 피드는 레코드를 나르고 커널은 조용히 버렸는데, 장부는 그것을 「통과」로
90
+ * 셌다. 화면은 멈춘 트윈 옆에 「150 통과 · 100%」라고 적었다 — 사실이 사라지는 동안 안심시킨 것이다.
91
+ *
92
+ * 고친 방법은 `ingestLive` 가 **넣은 수를 돌려주는** 것이다. 새 경로가 그 반환을 안 쓰고 0 을 넘기면
93
+ * 같은 침묵이 돌아온다. 그래서 경로마다 미전달을 세는지 소스에서 본다.
94
+ */
95
+ const missing = ingestCallSites()
96
+ .filter(s => !s.src.includes('undelivered'))
97
+ .map(s => s.file)
98
+
99
+ assert.deepEqual(
100
+ missing,
101
+ [],
102
+ `커널이 받은 수를 견주지 않는 유입 경로 ${missing.length}곳:\n ${missing.join('\n ')}\n\n` +
103
+ `TwinEngine.ingestLive(...) 의 반환(넣은 수)을 받아 accepted.length 와의 차이를 ` +
104
+ `recordIngestResult(..., nowMs, undelivered) 로 넘긴다. 그러지 않으면 트윈이 멈춘 사이 ` +
105
+ `버려진 레코드가 「통과」로 집계되어 화면이 100% 라고 말한다.`
106
+ )
107
+ })
108
+
109
+ test('제시 수를 거부 수로 대신하지 않는다 — 통과율의 분모가 뒤바뀌지 않게', () => {
110
+ /*
111
+ * 실수하기 쉬운 자리다: `recordIngestResult(..., rejected.length, rejected)` 처럼 적으면 분모가
112
+ * 거부 수가 되어 **통과율이 언제나 0** 이 된다. 오류 없이 「전부 거부」로 읽힌다.
113
+ */
114
+ const bad = ingestCallSites()
115
+ .filter(s => /recordIngestResult\([^)]*rejected\.length\s*,/.test(s.src))
116
+ .map(s => s.file)
117
+
118
+ assert.deepEqual(bad, [], `제시 수 자리에 거부 수를 넘긴 곳:\n ${bad.join('\n ')}`)
119
+ })
@@ -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
+ })