@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
@@ -61,19 +61,25 @@ export class TwinInstance {
61
61
  kind: string
62
62
 
63
63
  /*
64
- * realityMode — 이 트윈의 현실이 어디서 오는가(runtime-state-model §0·§1 "지속 현실 하나 + 여러 렌즈"의 ① 선언).
65
- * 'mirror' = 현실은 외부 실물 시스템. 트윈=순수 렌즈, 재기동 시 실물서 재동기(startLive).
66
- * 'sim-world' = 트윈이 낳은 타임라인이 곧 진실이며 이어진다 → 재기동 시 저널서 resume(현실 지속).
67
- * 'sim-experiment' = seed 재현형. 재기동 = 같은 우주 재실행(저널 reset, revision 0) — 백지 시작이 버그가 아니라 선언된 거동.
64
+ * **재기동 정책** — 이 트윈은 다시 세워질 자기 과거를 어떻게 대하나(ADR-0029 §2).
68
65
  *
69
- * **비어 있을 없다.** 값은 재기동 거동을 구분하는 선언이고, 없으면 읽는 쪽마다 각자
70
- * 기본값을 고르게 된다 같은 트윈이 부르는 자리에 따라 다르게 재기동하는 길이다.
71
- * 선언을 생략한 프로비저닝은 `register` `DEFAULT_REALITY_MODE` **각인해서 저장한다**.
72
- * 즉 "미선언" 이라는 상태 자체를 저장소에 두지 않는다.
66
+ * 'resync' = 외부 실물에서 다시 읽는다(원본이 진실이므로 우리 과거를 이어 붙이지 않는다 startLive).
67
+ * 'resume' = 저널을 이어 세운다(이 트윈이 낳은 타임라인이 사실이다).
68
+ * 'reset' = 저널을 비우고 씨앗부터 다시 돌린다(재현 가능한 실험 백지 시작이 선언된 거동이다).
69
+ *
70
+ * ── 왜 트윈에 남았나 (옛 이름 `realityMode`) ────────────────────────────────
71
+ * 「현실이 어디서 오나」는 ADR-0029 가 **원본의 종류**로 옮긴 물음이다(`TwinReference.adapterType`) —
72
+ * 트윈은 한 종류이고 언제나 원본을 반영한다. 그런데 코드가 이 축으로 실제로 가른 것은 **재기동 거동
73
+ * 하나**였고, 그것은 원본의 성질이 아니라 **트윈과 자기 과거의 관계**다: 같은 시뮬레이터 원본을 보는 두
74
+ * 트윈이 하나는 이어가고 하나는 매번 처음부터 재현할 수 있다. 그래서 축은 남고 이름만 바뀌었다.
75
+ *
76
+ * **비어 있을 수 없고, 기본값도 없다.** 예전에는 미선언이 조용히 `sim-experiment`(=저널 초기화)로
77
+ * 떨어졌다 — 값이 하나 어긋나는 순간 이력을 지우는 길이다. 이제 읽는 쪽이 **오류를 낸다**
78
+ * (`engine/restart-policy.ts`). 선언은 프로비저닝하는 쪽의 몫이다.
73
79
  */
74
80
  @Column()
75
- @Field({ description: "Reality source declaration: 'mirror' (external system is truth, resync on reboot) | 'sim-world' (generated timeline is truth and persists, resume on reboot) | 'sim-experiment' (seed-reproducible, resets on reboot)." })
76
- realityMode: string
81
+ @Field({ description: "Restart policy: 'resync' (read again from the external source) | 'resume' (continue this twin's own journal) | 'reset' (wipe and replay from the seed). No default — an unspecified policy is refused." })
82
+ restartPolicy: string
77
83
 
78
84
  /* 읽을 때 어휘 세대를 흡수한다(`nodes`/`movers`/`equipmentList` → `locations`/`equipment`). (vocabulary-guard: allow — 세대 이름 없이는 설명할 수 없다.)
79
85
  자리마다 리더를 쓰게 하면 다음에 추가되는 자리가 또 빠지고, 빠진 것은 오류가 아니라 **빈 공장**이라
@@ -1,9 +1,11 @@
1
+ import { twinWarn } from '../../engine/log.js'
1
2
  import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'
2
3
  import { Arg, Args, Ctx, Float, Int, Query, Resolver } from 'type-graphql'
3
4
 
4
5
  import { buildNextCursor, cursorSortings, getQueryBuilderFromListParams, getRepository, ListParam, ScalarObject } from '@things-factory/shell'
5
6
 
6
7
  import { TwinEvent } from '../twin-event/twin-event.js'
8
+ import { JOURNAL_COUNT_CAP } from '../twin-event/journal-count.js'
7
9
  import { TwinEventList } from '../twin-event/twin-event-type.js'
8
10
  import { TwinEngine } from '../../engine/index.js'
9
11
  import { pickAxes } from '../../engine/state-axes.js'
@@ -16,6 +18,12 @@ import { computeTwinKpi, resolveTwinTargets } from '../../engine/kpi-query.js'
16
18
  * twinReplay : 내구 저널을 replay 해 상태 재구성(재부팅 복구·시간여행) — untilRevision 으로 임의 과거.
17
19
  * (실시간 스트림은 twinState subscription, 명령은 twin-control mutation. 이건 '질의' 표면.)
18
20
  */
21
+ /*
22
+ * 건수 상한은 **저널 세기의 문**에서 온다(`journal-count.ts`) — 값과 규율이 두 곳에 흩어지면 한쪽만
23
+ * 고쳐진다. 근거(2026-08-22 실측): 상한 1만이면 0.04초, 상한 없이 세면 5.32초(저널 358만 행).
24
+ */
25
+ const COUNT_CAP = JOURNAL_COUNT_CAP
26
+
19
27
  /**
20
28
  * 시각 필터 값을 날짜로 — 배열(between)이면 원소마다.
21
29
  *
@@ -52,8 +60,31 @@ export class TwinJournalQuery {
52
60
  else if (fromRevision != null) where.revision = MoreThanOrEqual(fromRevision)
53
61
  else if (toRevision != null) where.revision = LessThanOrEqual(toRevision)
54
62
 
55
- /* 최신순(DESC) 기본 — Ledger/recent 뷰. 범위 필터로 시간여행 구간 조회도 가능. */
56
- return getRepository(TwinEvent).find({ where, order: { revision: 'DESC' }, take: limit ?? 200 })
63
+ /*
64
+ * 최신순(DESC) 기본 Ledger/recent 뷰. 범위 필터로 시간여행 구간 조회도 가능.
65
+ *
66
+ * ── 정렬 축을 **거르는 축과 맞춘다** (2026-08-22 실측) ─────────────────────
67
+ * `untilTime` 경로는 `eventTime <= T` 로 거르면서 `revision` 으로 정렬했다. 그러면 sqlite 는 조건에
68
+ * 맞는 행을 **전부 찾아 전부 정렬한 뒤** 상한을 적용한다 — 상한이 정렬 뒤에 걸리므로 200건을 얻는
69
+ * 데도 전량 정렬을 치른다. 실측(인스턴스 하나가 375만 행):
70
+ *
71
+ * ORDER BY revision DESC → 33.4초
72
+ * ORDER BY eventTime DESC, revision DESC → 0.007초 (4,700배)
73
+ *
74
+ * 계획이 그것을 그대로 말한다: 앞은 `USE TEMP B-TREE FOR ORDER BY`(전체 정렬), 뒤는
75
+ * `... FOR LAST TERM OF ORDER BY` — 시각은 인덱스(`ix_twin_event_1`)에서 순서대로 나오고
76
+ * **동시각만** 임시 정렬한다.
77
+ *
78
+ * **리비전 경로는 그대로 둔다.** 그때는 거르는 축이 `revision` 이고 정렬도 `revision` 이라 이미
79
+ * 맞아 있다(`ix_twin_event_0`). 축이 어긋난 경로만 고친다 — 맞는 자리를 건드리면 그쪽이 어긋난다.
80
+ *
81
+ * 순서의 뜻이 **완전히 같지는 않다**(측정해서 고친 문장): `revision` 순서와 `eventTime` 순서가
82
+ * 어긋나는 자리가 실재한다(426만 행 중 7건 — 미러는 원본의 시각을 싣고 도착 순서로 리비전을 받는다).
83
+ * 이 목록은 사람이 이력을 훑는 화면이고 **시각순으로 보는 것이 그 화면의 뜻**이므로, 그 어긋남은
84
+ * 결함이 아니라 오히려 맞는 순서다. 동시각의 순서를 위해 `revision` 을 둘째 축으로 남긴다.
85
+ */
86
+ const order: any = untilTime != null ? { eventTime: 'DESC', revision: 'DESC' } : { revision: 'DESC' }
87
+ return getRepository(TwinEvent).find({ where, order, take: limit ?? 200 })
57
88
  }
58
89
 
59
90
  /**
@@ -131,13 +162,36 @@ export class TwinJournalQuery {
131
162
  /* 대상 범위는 호출자가 필터로 빼먹을 수 있는 값이 아니다 — 계약상 필수라 여기서 강제한다. */
132
163
  qb.andWhere(`${qb.alias}.instanceId IN (:...instanceIds)`, { instanceIds })
133
164
 
134
- const [items, total] = await qb.getManyAndCount()
165
+ /*
166
+ * ── 건수는 **상한까지만** 센다 (2026-08-22 실측) ──────────────────────────
167
+ * 예전에는 `getManyAndCount()` 였다. 그 두 번째 질의가 조건에 맞는 행을 **전부** 세는데, 실측한
168
+ * 개발 저널에서 트윈 하나가 358만 행이라 **한 번에 5.32초**였다. 이력 화면을 열 때마다 그것을
169
+ * 물었고, 그 5초 동안 이벤트 루프가 다른 요청을 처리하지 못했다.
170
+ *
171
+ * 상한을 두면 같은 답이 **0.04초**다(130배). 상한을 넘으면 정확한 수를 모르는 것이 사실이므로
172
+ * 목록 규약의 「서버 상한에 부딪힘(total 모름)」 갈래로 낸다 — 화면은 「처음 N 건만」이라고 말하고
173
+ * 좁히기를 권한다. **조용히 자르지 않는다**: 상한을 넘었다는 사실을 `capped` 로 함께 낸다.
174
+ *
175
+ * 왜 하위 질의로 세지 않나: 5개 드라이버에서 같이 도는 형태여야 한다(이 저장소의 절대 규칙).
176
+ * 그래서 **키 컬럼만 상한까지 읽어 세는** 방식을 쓴다 — 색인만 읽으므로 행 본문을 만들지 않는다.
177
+ */
178
+ const items = await qb.getMany()
179
+ const probe = qb
180
+ .clone()
181
+ .orderBy() /* 세는 데 순서는 필요 없다 — 정렬을 남기면 임의 축에서 전체 정렬이 된다. */
182
+ .skip(0)
183
+ .take(COUNT_CAP + 1)
184
+ .select(`${qb.alias}.id`)
185
+ const counted = await probe.getRawMany()
186
+ const capped = counted.length > COUNT_CAP
187
+ /* 상한을 넘었으면 **전체 수를 모른다** — 상한값을 전체로 적으면 화면이 거짓을 말한다. */
188
+ const total = capped ? undefined : counted.length
135
189
 
136
190
  /* 다음 커서는 **이번 페이지의 마지막 행**에서 만든다. 페이지가 상한보다 짧으면 뒤가 없다. */
137
191
  const axes = cursorSortings(effective.sortings, 'id')
138
192
  const nextCursor = items.length === limit ? buildNextCursor(items[items.length - 1], axes) : undefined
139
193
 
140
- return { items, total, nextCursor }
194
+ return { items, total, capped, countCap: COUNT_CAP, nextCursor }
141
195
  }
142
196
 
143
197
  /**
@@ -231,7 +285,7 @@ export class TwinJournalQuery {
231
285
  const state = await TwinEngine.recover(context.state.domain.id, instanceId, untilRevision ?? undefined, untilTime ?? undefined)
232
286
  const picked = pickAxes(state, axes)
233
287
  /* 오타를 조용히 넘기지 않는다 — 화면이 빈 채로 뜨는 이유를 아무도 모르게 되는 것이 가장 나쁘다. */
234
- if (picked.unknown.length) console.warn(`[twin] twinReplay: unknown axes ignored for "${instanceId}": ${picked.unknown.join(', ')}`)
288
+ if (picked.unknown.length) twinWarn(`[twin] twinReplay: unknown axes ignored for "${instanceId}": ${picked.unknown.join(', ')}`)
235
289
  if (!attentionLimit || !picked.state?.attentions) return picked.state
236
290
  const d = digestAttentions(picked.state.attentions, attentionLimit)
237
291
  /* 잘랐다는 사실을 값으로 남긴다 — 개수만 줄어들면 화면은 "괜찮아졌다" 로 읽는다. */
@@ -1,8 +1,11 @@
1
1
  import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'
2
2
 
3
- import { ScalarObject } from '@things-factory/shell'
3
+ import { ScalarObject, getRepository } from '@things-factory/shell'
4
4
 
5
5
  import { TwinEngine } from '../../engine/index.js'
6
+ /* 정책 읽기는 그 모듈에서 직접 가져온다 — 다른 소비처(`reference-resolver`)와 같은 길이다. */
7
+ import { readRestartPolicy } from '../../engine/restart-policy.js'
8
+ import { TwinInstance } from '../twin-instance/twin-instance.js'
6
9
 
7
10
  import type { TwinModelDef } from '@operato/twin-kernel'
8
11
 
@@ -14,7 +17,8 @@ import { domainCatalog } from './domain-catalog.js'
14
17
  * 트윈 인스턴스 생명주기 채널 — 프로비저닝(상향, ADR-0015).
15
18
  * State/Command/Scenario 는 이미 running 인 인스턴스를 다루지만, 이 채널은 인스턴스 자체를 만들고/설정하고/없앤다.
16
19
  * 생명주기: provision(save, 미기동) → start → stop → delete. 모두 domain 스코프(context.state.domain.id).
17
- * model(TwinModelDef+layout)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은 controlTwinScenario 로.
20
+ * model(TwinModelDef+layout)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은 **원본이 선언하고**
21
+ * (`TwinReference.connectionConfig.scenario`) 기동이 싣거나, 원본의 구동 능력으로 태운다(ADR-0029 §4·§8).
18
22
  */
19
23
  @Resolver()
20
24
  export class TwinLifecycleMutation {
@@ -60,6 +64,45 @@ export class TwinLifecycleMutation {
60
64
  return true
61
65
  }
62
66
 
67
+ /**
68
+ * 이 트윈이 **자기 과거를 어떻게 대할 것인가**를 다시 선언한다 (2026-08-20).
69
+ *
70
+ * ── 왜 이 문이 필요한가 ────────────────────────────────────────────────────
71
+ * `restartPolicy` 는 사람이 하는 선언인데, 만들 때 한 번 정하면 **고칠 길이 없었다.** 그래서 「씨앗
72
+ * 재현용」으로 세운 트윈이 나중에 사람들이 이력을 보는 트윈이 되어도 선언은 `reset` 으로 남았다.
73
+ * 선언과 쓰임이 어긋난 채로 두면, 선언대로 구현하는 순간(저널 초기화) 이력을 잃는다.
74
+ *
75
+ * ── 지금 도는 것에는 손대지 않는다 ─────────────────────────────────────────
76
+ * 이 값은 **다음 기동**의 규칙이다. 돌고 있는 런타임의 구동 방식을 바꾸려면 정지·기동이 필요하고,
77
+ * 그 판단은 사람의 것이다(도는 트윈을 조용히 갈아 끼우면 그 사이의 사실이 어디로 갔는지 알 수 없다).
78
+ * 그래서 결과에 「지금 도는 것과 다르다」는 사실을 함께 낸다.
79
+ */
80
+ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
81
+ @Mutation(returns => ScalarObject, {
82
+ description:
83
+ "Re-declare how a twin treats its own past on the next boot: 'resync' (re-read from the source), 'resume' (carry its own journal forward) or 'reset' (reproduce from the seed, journal cleared). The value is read strictly — an unknown or legacy word is refused with the accepted words named, because a silent default would decide whether a twin's history survives. This changes the declaration only: a running runtime keeps the mode it was started with, and the result says so (appliesAtNextStart) rather than swapping it underneath."
84
+ })
85
+ async setTwinRestartPolicy(
86
+ @Arg('instanceId') instanceId: string,
87
+ @Arg('restartPolicy') restartPolicy: string,
88
+ @Ctx() context: ResolverContext
89
+ ): Promise<any> {
90
+ const domainId = context.state.domain.id
91
+ /* 값은 **엄격히** 읽는다 — 모르는 말을 기본값으로 메우면 그 트윈의 이력이 남을지가 조용히 결정된다. */
92
+ const policy = readRestartPolicy(restartPolicy, `twin "${instanceId}"`)
93
+ const repo = getRepository(TwinInstance)
94
+ const row = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
95
+ if (!row) throw new Error(`unknown twin "${instanceId}" in this domain — nothing was changed`)
96
+
97
+ const before = row.restartPolicy
98
+ if (before === policy) return { instanceId, restartPolicy: policy, changed: false, appliesAtNextStart: false }
99
+
100
+ await repo.update({ id: row.id }, { restartPolicy: policy })
101
+ /* 도는 런타임의 구동 방식은 그대로다 — 그 사실을 숨기지 않는다. */
102
+ const running = !!TwinEngine.metrics(domainId, instanceId)
103
+ return { instanceId, restartPolicy: policy, previous: before, changed: true, appliesAtNextStart: running }
104
+ }
105
+
63
106
  /** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */
64
107
  @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
65
108
  @Mutation(returns => Boolean, { description: 'Delete a twin instance: stop it, remove the registry entry and purge its event journal.' })
@@ -81,9 +124,41 @@ export class TwinLifecycleMutation {
81
124
  return TwinEngine.instanceIdsOfSpace(context.state.domain.id, spaceId)
82
125
  }
83
126
 
84
- @Query(returns => [ScalarObject], { description: 'List provisioned twin instances in the current domain with management metadata.' })
127
+ @Query(returns => [ScalarObject], {
128
+ description:
129
+ 'List provisioned twin instances in the current domain with management metadata. Each row also carries `sync`: a one-line answer to "is this twin keeping in step with the floor?" — a verdict (syncing | mapping-blocked | no-traffic | not-connected | not-applicable | unknown), the accepted share of the last closed 10-minute window, and up to 36 closed windows of offered/rejected counts for a sparkline. Reasons and rejected-record samples are deliberately absent here; ask twinIngestHealth for one twin when you need them, because carrying raw records for every row would make this list heavy for no gain. acceptedRatio is null while the first window is still filling — a partially filled window under-counts, and drawing it as zero would read as "nothing passed".'
130
+ })
85
131
  async twinInstanceList(@Ctx() context: ResolverContext): Promise<any[]> {
86
- return TwinEngine.list(context.state.domain.id)
132
+ const domainId = context.state.domain.id
133
+ const list = await TwinEngine.list(domainId)
134
+ /*
135
+ * 동기화 요약을 **같은 응답에 얹는다.** 따로 질의하면 트윈 수만큼 왕복이 생기고(N+1), 그 사이 화면은
136
+ * 판정 없는 목록을 먼저 그린다 — 사람이 그 순간을 「정상」으로 읽는다.
137
+ */
138
+ const brief = new Map(
139
+ TwinEngine.ingestHealthBrief(
140
+ domainId,
141
+ list.map((x: any) => x.instanceId)
142
+ ).map(b => [b.instanceId, b])
143
+ )
144
+ /*
145
+ * **쓰기 사실도 같은 응답에 얹는다** (2026-08-20).
146
+ *
147
+ * 「이 트윈이 사실을 적고 있나」는 운영자가 트윈 화면에서 묻는 질문인데, 그 답은 벤치(슈퍼유저)의
148
+ * 부하 계기판에만 있었다. 유입(`sync`)과 나란히 두는 이유는 둘이 한 사슬이기 때문이다 — 받은 것이
149
+ * 있어도 적히지 않으면 저널이 비고, 그러면 성과·시간여행이 함께 빈다.
150
+ *
151
+ * 계기는 메모리라 왕복이 없다. **도는 트윈만** 답이 있고, 멈춘 트윈은 `null` 이다(0 이 아니다 —
152
+ * 「쓰지 않았다」와 「돌지 않는다」는 다른 사실이다).
153
+ */
154
+ return list.map((x: any) => {
155
+ const m = TwinEngine.metrics(domainId, x.instanceId)
156
+ return {
157
+ ...x,
158
+ sync: brief.get(x.instanceId) ?? null,
159
+ write: m ? { rate: m.journalRate, journaled: m.journaledTotal, backlog: m.backlog } : null
160
+ }
161
+ })
87
162
  }
88
163
 
89
164
  /** 단건 상세 — 프로비저닝 에디터용 model(구조+layout) 포함. */
@@ -3,18 +3,30 @@ import { Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
3
3
  import { In } from 'typeorm'
4
4
 
5
5
  import { Domain, getRepository, ScalarObject } from '@things-factory/shell'
6
+ import { PrivilegeObject, checkPermission } from '@things-factory/auth-base'
6
7
 
7
8
  import { TwinEngine } from '../../engine/index.js'
8
9
  import { newCpuSampler, sampleCpu } from '../../engine/load-meter.js'
10
+ import { modelGapOf } from '../../engine/model-gap.js'
9
11
 
10
12
  /*
11
13
  * 트윈 처리량 계측(모니터, ④-1) — 라이브 동기 인터페이스 부하를 읽는 일급 지표.
12
- * 유입률(ingestRate)·방송률(broadcastRate)·저널률(journalRate)·백로그(backlog)·누적. 폴링으로 라이브 관측.
13
- * (부하가 화면에 안 보인다던 문제의 정공법 — "얼마나 들어오고 얼마나 방송/기록되나"를 수치로.)
14
+ * 유입률(ingestRate)·브로드캐스팅률(broadcastRate)·저널률(journalRate)·백로그(backlog)·누적. 폴링으로 라이브 관측.
15
+ * (부하가 화면에 안 보인다던 문제의 정공법 — "얼마나 들어오고 얼마나 브로드캐스팅/기록되나"를 수치로.)
14
16
  */
15
17
  /* CPU 는 누적값이라 **표본 사이의 차이**로만 점유율이 나온다. 그래서 호출 사이에 상태가 남아야 한다. */
16
18
  const CPU_SAMPLER = newCpuSampler()
17
19
 
20
+ /**
21
+ * 거부된 **레코드 원문**을 볼 수 있는 자격.
22
+ *
23
+ * 트윈을 고칠 수 있는 사람과 같은 자격으로 둔다 — 매핑을 고치려면 원천이 실제로 보낸 모양을 봐야 하므로
24
+ * 권한이 그 일과 맞는다. 도메인 소유자·슈퍼유저는 통과한다(프레임워크 규약과 같다).
25
+ *
26
+ * 판정·추이·사유는 이 자격과 무관하게 낸다. 「맞춰지고 있나」는 감출 일이 아니다.
27
+ */
28
+ const RAW_RECORD_PRIVILEGE: PrivilegeObject = { category: 'twin', privilege: 'mutation', owner: true, super: true }
29
+
18
30
  @Resolver()
19
31
  export class TwinMetricsQuery {
20
32
  @Query(returns => ScalarObject, { nullable: true, description: 'Live throughput metrics of one twin instance (ingest/broadcast/journal rate, backlog). Null if not a live instance or not in the current domain.' })
@@ -23,11 +35,77 @@ export class TwinMetricsQuery {
23
35
  return TwinEngine.metrics(context.state.domain.id, instanceId)
24
36
  }
25
37
 
26
- @Query(returns => [ScalarObject], { description: 'Live throughput metrics of all running live twin instances in the current domain (monitor dashboard).' })
38
+ @Query(returns => [ScalarObject], {
39
+ description:
40
+ 'Throughput metrics of every running twin instance in the current domain — simulations as well as mirrors (monitor dashboard). Simulations report ingestRate 0 because they receive nothing from a source: they emit their own facts, and that zero is a fact rather than a gap. Until 2026-08-20 simulations carried no meter at all and were silently absent from this list, which made a host full of running simulations look idle.'
41
+ })
27
42
  async twinMetricsAll(@Ctx() context: ResolverContext): Promise<any[]> {
28
43
  return TwinEngine.allMetrics(context.state.domain.id) // 도메인 스코프
29
44
  }
30
45
 
46
+ /*
47
+ * ── 동기화 건강 — 「이 트윈이 현장과 맞춰지고 있나」 ──────────────────────────
48
+ *
49
+ * `twinMetrics` 는 **통과한** 이벤트의 초당 수를 답한다. 여기서 답하는 것은 **제시된 것 중 얼마가
50
+ * 통과했나**이고, 통과하지 못한 것의 이유와 실제 모양까지 낸다.
51
+ *
52
+ * 왜 따로 있나: 거부된 레코드는 **저널에 없다**(거부의 뜻이 그것이다). 저널을 접어 나오는 것은 통과한
53
+ * 쪽뿐이라, 거부의 추이는 세는 자리를 따로 두어야 나온다.
54
+ *
55
+ * 왜 도메인으로 자르나: 이것은 **테넌트의 데이터**다. 거부 표본에는 그 고객의 레코드 원문이 들어
56
+ * 있으므로 남의 것을 보여 줄 수 없다(부하 계기판이 프로세스 전체를 내는 것과 반대다 — 그쪽은
57
+ * 이벤트 루프가 프로세스 공유라서 그렇게 했다).
58
+ *
59
+ * ── 수치와 원문은 민감도가 다르다 ───────────────────────────────────────────
60
+ * 판정·추이·사유는 「맞춰지고 있나」를 알려면 누구나 봐야 한다. 반면 **거부된 레코드 원문**은 그 고객의
61
+ * 업무 데이터다(품목·수량·거래처가 그대로 들어 있다). 같은 도메인이라는 것만으로 원문까지 열면 필요
62
+ * 없는 사람에게도 열린다.
63
+ *
64
+ * 그래서 원문만 한 겹 뒤에 둔다 — 트윈을 **고칠 수 있는 사람**에게만. 매핑을 고치려면 원천이 실제로
65
+ * 무엇을 보냈는지 봐야 하므로 권한이 그 일과 맞는다. 감출 때는 **감췄다고 말한다**(`samplesWithheld`);
66
+ * 빈 배열로 두면 「거부된 것이 없다」로 읽힌다.
67
+ */
68
+ @Query(returns => ScalarObject, {
69
+ description:
70
+ 'Answers one question: is this twin keeping in step with the floor? Returns a verdict — syncing | mapping-blocked | no-traffic | not-connected | not-applicable | unknown — together with the threshold it was judged by, so a screen can say why. `recent` is the last **closed** 10-minute window (null while the first window is still filling: a partially filled window under-counts, so it is not called "recent"), and `trend` carries up to 36 closed windows (6 hours) oldest-first with offered / accepted / rejected per window. Accepted and rejected are reported together because counting only rejections makes "nothing is arriving" and "everything is fine" both look like zero. `reasons` groups rejections by the reason the kernel gave, most frequent first, each with the last time that reason occurred — a reason whose time has stopped is one you already fixed. `samples` holds one rejected record per reason (the raw record, truncated with recordTruncated when long) because a reason alone does not tell you what the source actually sent. Counts that could not be shown are reported rather than dropped: sampleDropped, trendDropped, and reasonsCapped when distinct reasons hit their limit and the rest were folded into "(other reasons)". A twin that has never received anything reports null figures rather than zeros, since never-measured and measured-as-zero are different facts. This ledger lives in memory: it covers roughly the last 6 hours and resets when the process restarts. The verdict, trend and reasons are returned to anyone in the owning domain, but the raw records in `samples` are business data of that tenant, so they require the same privilege as changing the twin (twin/mutation, with domain owner and superuser granted); when the caller lacks it, `samples` comes back empty and `samplesWithheld` says how many were held back — an empty list alone would read as "nothing was rejected". `modelGap` answers a different question from the verdict: the verdict is about the feed (is anything arriving, does it pass), while modelGap is about the model (did we declare what the source knows). The ingest gate deliberately does not reject a record that references an id the master never declared — a source may legitimately report a new place before the structure sync catches up, and rejecting it would lose that measurement. The kernel absorbs it and marks it `origin: "observed"` instead, and modelGap counts those per axis (locations / equipment / persons) with a capped id list plus how many more were left out. It also carries the kernel\'s `unhandled` tally — event kinds the twin could not apply at all — and, for energy twins, how many state reports arrived from equipment the model does not know. `notCovered` names axes this cannot answer yet: meters carry no origin mark in the kernel, so a mistyped meterId silently becomes a new meter whose kW is accumulated into the site demand. Null while the twin is not running: there is nothing to compare against, and never-compared is not the same as none. Null if the instance is not in the current domain.'
71
+ })
72
+ async twinIngestHealth(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<any> {
73
+ const domainId = context.state.domain.id
74
+ /*
75
+ * 테넌트 격리 — 다만 `owns` 는 **도는 트윈**만 참이다. 멈춘 트윈의 장부도 볼 수 있어야 하므로
76
+ * (「고치려고 멈춰 뒀는데 마지막에 뭐가 거부됐나」), 등록 여부로도 판정한다. 도는 트윈이면 DB 를
77
+ * 묻지 않는다(대부분의 조회가 그쪽이다).
78
+ */
79
+ if (!TwinEngine.owns(domainId, instanceId) && !(await TwinEngine.ownsRegistered(domainId, instanceId))) return null
80
+
81
+ const view = TwinEngine.ingestHealthOf(domainId, instanceId)
82
+
83
+ /*
84
+ * ── 「원본이 말한 적 없는 것」을 함께 낸다 (2026-08-20) ──────────────────────
85
+ * 유입 문은 참조 오류를 거부하지 않는다(실측: 모르는 로케이션·bizStep·계량기가 모두 통과). 거부하지
86
+ * 않는 것이 옳다 — 원천이 모델보다 먼저 새 자리를 말하는 것은 정상이고, 거부하면 그 선행 계측을
87
+ * 잃는다. 대신 커널이 **흡수하고 표시한다**(`origin: 'observed'`).
88
+ *
89
+ * 그 표시를 읽는 곳이 모델 인스펙션 화면 하나였다. 그런데 「이 트윈이 현장과 맞춰지고 있나」를
90
+ * 보는 사람이 바로 그것을 알아야 한다 — 원천이 우리가 모르는 로케이션만 보내면 트윈은 도는데
91
+ * 스키매틱은 비어 있고, 그게 우리가 「빈 공장」이라 불러 온 상태다.
92
+ *
93
+ * 판정에는 섞지 않는다. 판정은 **피드**를 말하고 이것은 **모델**을 말한다(`model-gap.ts`).
94
+ */
95
+ const modelGap = modelGapOf(TwinEngine.snapshot(domainId, instanceId))
96
+
97
+ /* 원문을 볼 수 있나 — 못 보면 **몇 건을 감췄는지** 함께 낸다(빈 배열은 「없었다」로 읽힌다). */
98
+ const maySeeRecords = await checkPermission(
99
+ RAW_RECORD_PRIVILEGE,
100
+ context.state.user,
101
+ context.state.domain
102
+ )
103
+ if (maySeeRecords) return { ...view, modelGap }
104
+
105
+ const { samples, ...rest } = view
106
+ return { ...rest, modelGap, samples: [], samplesWithheld: samples.length }
107
+ }
108
+
31
109
  /*
32
110
  * ── 이벤트 루프 부하 — **운영 계기판이다. 테넌트 기능이 아니다.** ─────────────
33
111
  *
@@ -0,0 +1,57 @@
1
+ /*
2
+ * **저널에 그 축의 사실이 있나** — 「아직 없다」와 「왔는데 상태에 없다」를 가른다 (2026-08-22).
3
+ *
4
+ * ── 왜 필요했나 (실측) ──────────────────────────────────────────────────────
5
+ * 화면이 「작업 — 아직 하나도 없습니다」라고 말하는데, 그 트윈의 저널에는 `task.status` 5,786건이
6
+ * 있었고 **마지막 상태가 `in-progress`** 였다. 사용자가 「작업 지시 835건인데 작업이 0인 이유」를
7
+ * 물어 반나절을 조사해 원인을 찾았다: 관측 모드 커널이 관측기의 작업을 스냅샷으로 내보내지 않는다
8
+ * (커널 자리이고 그 레인에 넘겼다).
9
+ *
10
+ * 화면 몫은 다른 것이다. **카드가 단정하면 안 된다.** 「아직 하나도 없습니다」는 「선언은 있고 사실이
11
+ * 아직 일어나지 않았다」는 뜻인데, 실제로는 「사실이 일어났는데 상태에 오지 못했다」였다 — 그 차이가
12
+ * 사용자를 반나절 조사로 보낸다.
13
+ *
14
+ * 그래서 상태가 빈 축에 대해 **저널을 한 건만 물어본다.** 있으면 화면이 그렇게 말한다.
15
+ *
16
+ * ── 어휘의 집 ───────────────────────────────────────────────────────────────
17
+ * 어느 사건이 어느 축을 채우는지는 **커널이 선언한 낱말**에서 온다(`OP_EVENT`·`ENERGY_EVENT`, 그리고
18
+ * EPCIS 접두사). 여기서 이름을 새로 만들지 않는다 — 커널이 낱말을 늘리면 이 표에 한 줄이 늘고,
19
+ * 표에 없는 축은 **아무 말도 하지 않는다**(모르는 것을 지어내지 않는다).
20
+ */
21
+ import { anyJournalRow } from '../twin-event/journal-count.js'
22
+
23
+ /**
24
+ * 상태 축 → 그 축을 채우는 사건 낱말. 표에 없으면 판단하지 않는다.
25
+ *
26
+ * 물품은 EPCIS 로 온다(사건 이름이 여러 개다) — 그래서 접두사로 본다.
27
+ */
28
+ const AXIS_EVENTS: Record<string, { types?: string[]; prefix?: string }> = {
29
+ tasks: { types: ['task.status'] },
30
+ orders: { types: ['order.status'] },
31
+ items: { prefix: 'epcis.' },
32
+ demandWindows: { types: ['energy.demand.window'] }
33
+ }
34
+
35
+ /** 이 축의 사실이 저널에 한 건이라도 있나. 판단할 수 없으면 `null`(모른다). */
36
+ export async function axisSeenInJournal(
37
+ repo: any,
38
+ where: Record<string, unknown>,
39
+ axis: string
40
+ ): Promise<boolean | null> {
41
+ const spec = AXIS_EVENTS[axis]
42
+ if (!spec) return null
43
+
44
+ if (spec.types) {
45
+ for (const t of spec.types) {
46
+ if (await anyJournalRow(repo, { ...where, eventType: t })) return true
47
+ }
48
+ return false
49
+ }
50
+ /*
51
+ * 접두사로 찾는 축(물품) — 한 건만 있으면 된다. `Like` 를 쓰는 대신 **낱말 목록**을 쓰지 않는 이유는
52
+ * EPCIS 사건 이름이 커널에서 자라기 때문이다(객체·집약·변환·거래…). 이름을 여기 베끼면 커널이
53
+ * 하나 늘릴 때 이 자리가 조용히 낡는다.
54
+ */
55
+ const { Like } = await import('typeorm')
56
+ return anyJournalRow(repo, { ...where, eventType: Like(`${spec.prefix}%`) })
57
+ }
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * ISA-95 표와 같은 규율이다(`isa95-coverage.ts` 머리말): 사람이 판단해 적고, 틀리면 여기를 고친다.
17
17
  */
18
- import type { Isa95Concept, CoverageLevel } from './isa95-coverage.js'
18
+ import { reconcileAxes, type Isa95Concept, type CoverageLevel } from './isa95-coverage.js'
19
19
 
20
20
  /* Part: EPCIS 2.0 의 사건 종류 — 무엇이 일어났는가. */
21
21
  const EVENTS: Isa95Concept[] = [
@@ -179,13 +179,7 @@ export function epcisCoverage(axes: string[]): {
179
179
  concepts: (Isa95Concept & { axisMissing?: boolean })[]
180
180
  totals: { concepts: number; structure: number; behavior: number; surface: number }
181
181
  } {
182
- const known = new Set(axes)
183
- const concepts = EPCIS_CONCEPTS.map(c => {
184
- const axisMissing = !!c.axis && !known.has(c.axis)
185
- const shownOn = c.shownOn?.filter(a => known.has(a))
186
- const fixed = shownOn && shownOn.length !== c.shownOn?.length ? { ...c, shownOn } : c
187
- return axisMissing ? { ...fixed, structure: 'none' as CoverageLevel, axisMissing } : fixed
188
- })
182
+ const concepts = EPCIS_CONCEPTS.map(c => reconcileAxes(c, new Set(axes)))
189
183
  const count = (k: 'structure' | 'behavior' | 'surface') => concepts.filter(c => c[k] === 'full').length
190
184
  return {
191
185
  model: 'GS1 EPCIS 2.0',
@@ -14,7 +14,7 @@
14
14
  * ISA-95 표와 같은 규율이다(`isa95-coverage.ts` 머리말): 사람이 판단해 적고, 틀리면 여기를 고친다.
15
15
  * 자동으로 알 수 있는 것 하나만 자동으로 한다 — 축 이름이 커널 축 목록에 없으면 구조가 없는 것이다.
16
16
  */
17
- import type { Isa95Concept, CoverageLevel } from './isa95-coverage.js'
17
+ import { reconcileAxes, type Isa95Concept, type CoverageLevel } from './isa95-coverage.js'
18
18
 
19
19
  /*
20
20
  * Part 7-4 — 논리 노드 클래스. 이 프로파일이 쓰는 것만 적는다(표준 전체를 옮기지 않는다):
@@ -100,13 +100,7 @@ export function iec61850Coverage(axes: string[]): {
100
100
  concepts: (Isa95Concept & { axisMissing?: boolean })[]
101
101
  totals: { concepts: number; structure: number; behavior: number; surface: number }
102
102
  } {
103
- const known = new Set(axes)
104
- const concepts = IEC61850_CONCEPTS.map(c => {
105
- const axisMissing = !!c.axis && !known.has(c.axis)
106
- const shownOn = c.shownOn?.filter(a => known.has(a))
107
- const fixed = shownOn && shownOn.length !== c.shownOn?.length ? { ...c, shownOn } : c
108
- return axisMissing ? { ...fixed, structure: 'none' as CoverageLevel, axisMissing } : fixed
109
- })
103
+ const concepts = IEC61850_CONCEPTS.map(c => reconcileAxes(c, new Set(axes)))
110
104
  const count = (k: 'structure' | 'behavior' | 'surface') => concepts.filter(c => c[k] === 'full').length
111
105
  return {
112
106
  /* 이름에 (vocabulary) 를 달아 둔다 — 프로토콜 적합성으로 읽히지 않게. */
@@ -166,20 +166,33 @@ export const ISA95_CONCEPTS: Isa95Concept[] = [...PART2, ...PART4]
166
166
  * `axis` 를 커널 축 목록과 맞춰 본다: 축이 있다고 적혀 있는데 커널에 없으면 그 선언이 낡은 것이므로
167
167
  * **구조 없음으로 낮추고 그 사실을 말한다**(조용히 통과시키면 이 표가 거짓이 된다).
168
168
  */
169
+ /**
170
+ * 선언한 축을 커널의 축 목록과 맞춘다 — **어긋난 것을 조용히 지우지 않는다.**
171
+ *
172
+ * 두 자리가 어긋날 수 있다.
173
+ * · `axis` — 그 개념 자신의 문. 커널에 없으면 구조를 「없음」으로 낮추고 그 사실을 말한다.
174
+ * · `shownOn` — 그 사실을 들고 있는 다른 축. 예전에는 없는 축을 **말없이 걸러 냈다.** 그러면 그 줄은
175
+ * 「개념 축이 없습니다」로 보이고, 사람은 그것을 **원래 그런 것**으로 읽는다. 실제로 그 일이 있었다:
176
+ * EPCIS 표의 일곱 줄이 커널에 없는 축(`items`)에 기대고 있었는데, 경고가 뜬 것은 두 줄뿐이었다.
177
+ *
178
+ * 둘 중 하나라도 어긋나면 `axisMissing` 이다 — 표가 스스로 「최신이 아니다」라고 말하게 한다.
179
+ */
180
+ export function reconcileAxes(c: Isa95Concept, known: Set<string>): Isa95Concept & { axisMissing?: boolean } {
181
+ const ownMissing = !!c.axis && !known.has(c.axis)
182
+ const shownOn = c.shownOn?.filter(a => known.has(a))
183
+ const lostShown = !!c.shownOn && (shownOn?.length ?? 0) !== c.shownOn.length
184
+ const fixed = lostShown ? { ...c, shownOn } : c
185
+ if (!ownMissing && !lostShown) return fixed
186
+ /* 구조를 낮추는 것은 **자기 문이 없어진 경우**다 — 남의 축에서 보이던 것은 구조가 아니라 표시의 문제다. */
187
+ return ownMissing ? { ...fixed, structure: 'none' as CoverageLevel, axisMissing: true } : { ...fixed, axisMissing: true }
188
+ }
189
+
169
190
  export function isa95Coverage(axes: string[]): {
170
191
  model: string
171
192
  concepts: (Isa95Concept & { axisMissing?: boolean })[]
172
193
  totals: { concepts: number; structure: number; behavior: number; surface: number }
173
194
  } {
174
- const known = new Set(axes)
175
- const concepts = ISA95_CONCEPTS.map(c => {
176
- const axisMissing = !!c.axis && !known.has(c.axis)
177
- /* 가리키는 곳이 없어진 축은 **가리키지 않는다** — 죽은 문으로 보내면 화면이 거짓말을 한다.
178
- `axis` 와 같은 규율이고, 여기가 비면 그 줄은 다시 "축이 없다" 로 정직하게 돌아간다. */
179
- const shownOn = c.shownOn?.filter(a => known.has(a))
180
- const fixed = shownOn && shownOn.length !== c.shownOn?.length ? { ...c, shownOn } : c
181
- return axisMissing ? { ...fixed, structure: 'none' as CoverageLevel, axisMissing } : fixed
182
- })
195
+ const concepts = ISA95_CONCEPTS.map(c => reconcileAxes(c, new Set(axes)))
183
196
  /* 채운 수는 `full` 만 센다 — 부분을 채운 것으로 세면 기준이 스스로를 속인다. */
184
197
  const count = (k: 'structure' | 'behavior' | 'surface') => concepts.filter(c => c[k] === 'full').length
185
198
  return {
@@ -0,0 +1,80 @@
1
+ /*
2
+ * **개체에서 품목으로** — 정체성 모양이 하나가 아니다 (2026-08-23).
3
+ *
4
+ * ── 무엇이 조용히 비어 있었나 ───────────────────────────────────────────────
5
+ * 화면이 「이 개체는 선언된 자재가 아닙니다(라인 내부의 중간 산물일 수 있습니다)」라고 말했는데, 그
6
+ * 품목은 원본의 선언에 이름까지 있었다(「냉동그린빈스」). 대응 표(`productionSpec.binding`)도 멀쩡했다.
7
+ *
8
+ * 원인은 **개체에서 품목을 꺼내는 규칙**이었다. 그 규칙이 GS1 SGTIN URN 하나만 알았다.
9
+ *
10
+ * urn:epc:id:sgtin:0614141.077101.42 → 077101 (알던 모양)
11
+ * https://…/class/product-M0000438@M2R0001 → null (모르던 모양)
12
+ *
13
+ * 웹 URI 로 정체성을 말하는 원본에서는 언제나 `null` 이 나왔고, 그래서 **개체를 자재에 이을 수 없었다.**
14
+ * 그 결과가 화면 두 곳에서 조용한 빈칸이었다(계보 적합성 · 오더가 무엇을 만드나). 「없으면 붙이지
15
+ * 않는다」는 규율을 지키기 때문에 아무 오류도 나지 않았다.
16
+ *
17
+ * ── 무엇을 돌려주나 ─────────────────────────────────────────────────────────
18
+ * **그 트윈의 대응 표가 쓰는 것과 같은 모양**이다. 두 세계가 서로 다른 것을 값으로 쓴다.
19
+ *
20
+ * SGTIN 세계 binding 값 = 품목 참조 숫자(`077101`) → 숫자를 돌려준다
21
+ * URI 세계 binding 값 = 클래스 URI(`…/product-M…`) → 클래스 URI 를 돌려준다
22
+ *
23
+ * 그래서 두 세계를 한 함수가 답한다 — 부르는 자리가 원본의 정체성 방식을 알지 않아도 된다.
24
+ *
25
+ * ── 무엇을 지어내지 않나 ────────────────────────────────────────────────────
26
+ * **어느 부호가 개체를 가리키는지 모른다.** 그것은 원본마다 다르고 커널 계약이 정한 적이 없다 — 한
27
+ * 원본(chef)이 쓰는 `@` 를 규칙으로 굳히면 이 화면이 그 레거시 위에 서게 된다. 자르는 판단은 그 트윈의
28
+ * **선언**이 하고(§`resolveByDeclaration`), 후보가 둘 이상이면 답하지 않는다. 짐작으로 이으면 남의
29
+ * 레시피와 견주게 되고, 그것이 「설계와 다르다」는 거짓 진단이 된다.
30
+ */
31
+
32
+ /**
33
+ * 개체 식별자 → **품목 참조**. 대응 표의 값과 견줄 수 있는 모양으로 돌려준다.
34
+ *
35
+ * SGTIN 은 표준이 자리를 정해 두었으므로 그 자리에서 품목 참조를 꺼낸다. 그 밖의 모양은 **손대지
36
+ * 않는다** — 어느 부호가 개체를 가리키는지는 원본마다 다르고, 커널 계약이 그 구분자를 정한 적이 없다.
37
+ * 앞서 이 자리에 「`@` 뒤는 개체다」를 넣었는데, 그것은 한 원본(chef)의 모양을 규칙으로 굳힌 것이었다.
38
+ *
39
+ * 그래서 자르는 일은 여기서 하지 않고, **그 트윈의 선언과 견주어** 판정한다(§`resolveByDeclaration`).
40
+ * 선언이 답을 주지 못하면 `null` 이다 — 짐작으로 이으면 남의 레시피와 견주게 되고, 그것이 「설계와
41
+ * 다르다」는 거짓 진단이 된다.
42
+ */
43
+ export function itemRefOf(epc: unknown): string | null {
44
+ if (typeof epc !== 'string') return null
45
+ const s = epc.trim()
46
+ if (!s) return null
47
+
48
+ /* GS1 SGTIN — 회사 접두사와 일련번호 사이의 품목 참조. `idpat` 의 `*` 도 같은 자리에서 걸린다. */
49
+ const sgtin = /sgtin:[^.]+\.([^.]+)\./.exec(s)
50
+ if (sgtin) return sgtin[1]
51
+
52
+ /* 그 밖의 정체성(웹 URI 등)은 그대로 낸다 — 자르는 판단은 선언이 한다. */
53
+ return s
54
+ }
55
+
56
+ /**
57
+ * 이 식별자가 가리키는 **선언 항목**을 찾는다 — 원본의 정체성 관행을 알지 않고, 구분자도 모른다.
58
+ *
59
+ * 세 가지를 차례로 본다. 어느 단계든 **후보가 둘 이상이면 답하지 않는다**(모르는 것이 틀린 답보다 낫다).
60
+ *
61
+ * ① 그대로 같다
62
+ * ② 관측된 식별자가 선언을 **확장**한다 — 개체는 자기 클래스로 시작한다(`…/product-M1` + 개체 표시)
63
+ * ③ 화면이 **짧게 줄인** 식별자를 건넸다 — 선언이 그것으로 끝난다(`net/class/product-M1`)
64
+ *
65
+ * ②③은 부호를 모른다. 그래서 원본이 `@`·`#`·`/` 무엇을 쓰든, 또는 아무것도 쓰지 않든 같은 규칙이 선다.
66
+ */
67
+ export function resolveByDeclaration<T>(ref: string | null, declared: Map<string, T>): T | undefined {
68
+ if (!ref) return undefined
69
+ const exact = declared.get(ref)
70
+ if (exact !== undefined) return exact
71
+ let hit: T | undefined
72
+ let found = 0
73
+ for (const [id, value] of declared) {
74
+ if (!(ref.startsWith(id) || id.endsWith(ref))) continue
75
+ found++
76
+ if (found > 1) return undefined
77
+ hit = value
78
+ }
79
+ return hit
80
+ }