@things-factory/headless-twin 10.0.12 → 10.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +57 -2
  2. package/dist-server/engine/canonical-ingest.js +43 -2
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/command-routing.js +1 -1
  5. package/dist-server/engine/command-routing.js.map +1 -1
  6. package/dist-server/engine/index.d.ts +3 -0
  7. package/dist-server/engine/index.js +4 -0
  8. package/dist-server/engine/index.js.map +1 -1
  9. package/dist-server/engine/ingest-health.d.ts +335 -0
  10. package/dist-server/engine/ingest-health.js +434 -0
  11. package/dist-server/engine/ingest-health.js.map +1 -0
  12. package/dist-server/engine/integration-coverage.d.ts +76 -0
  13. package/dist-server/engine/integration-coverage.js +73 -0
  14. package/dist-server/engine/integration-coverage.js.map +1 -0
  15. package/dist-server/engine/integration-probes.d.ts +65 -0
  16. package/dist-server/engine/integration-probes.js +100 -0
  17. package/dist-server/engine/integration-probes.js.map +1 -0
  18. package/dist-server/engine/integration-runner.d.ts +45 -0
  19. package/dist-server/engine/integration-runner.js +59 -0
  20. package/dist-server/engine/integration-runner.js.map +1 -0
  21. package/dist-server/engine/integration-target-profile.d.ts +57 -0
  22. package/dist-server/engine/integration-target-profile.js +79 -0
  23. package/dist-server/engine/integration-target-profile.js.map +1 -0
  24. package/dist-server/engine/kpi-fold.d.ts +27 -0
  25. package/dist-server/engine/kpi-fold.js +47 -4
  26. package/dist-server/engine/kpi-fold.js.map +1 -1
  27. package/dist-server/engine/kpi-query.js +128 -14
  28. package/dist-server/engine/kpi-query.js.map +1 -1
  29. package/dist-server/engine/live-feed-registry.js +2 -16
  30. package/dist-server/engine/live-feed-registry.js.map +1 -1
  31. package/dist-server/engine/load-meter.d.ts +1 -1
  32. package/dist-server/engine/load-meter.js +12 -3
  33. package/dist-server/engine/load-meter.js.map +1 -1
  34. package/dist-server/engine/log.d.ts +18 -0
  35. package/dist-server/engine/log.js +80 -0
  36. package/dist-server/engine/log.js.map +1 -0
  37. package/dist-server/engine/loop-lag.d.ts +54 -0
  38. package/dist-server/engine/loop-lag.js +87 -0
  39. package/dist-server/engine/loop-lag.js.map +1 -0
  40. package/dist-server/engine/model-gap.d.ts +108 -0
  41. package/dist-server/engine/model-gap.js +95 -0
  42. package/dist-server/engine/model-gap.js.map +1 -0
  43. package/dist-server/engine/restart-policy.d.ts +3 -3
  44. package/dist-server/engine/restart-policy.js +4 -4
  45. package/dist-server/engine/restart-policy.js.map +1 -1
  46. package/dist-server/engine/runtime-key.js +1 -1
  47. package/dist-server/engine/runtime-key.js.map +1 -1
  48. package/dist-server/engine/stage-path.d.ts +83 -0
  49. package/dist-server/engine/stage-path.js +118 -0
  50. package/dist-server/engine/stage-path.js.map +1 -0
  51. package/dist-server/engine/twin-engine.d.ts +352 -16
  52. package/dist-server/engine/twin-engine.js +1037 -166
  53. package/dist-server/engine/twin-engine.js.map +1 -1
  54. package/dist-server/index.js +34 -2
  55. package/dist-server/index.js.map +1 -1
  56. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
  57. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
  58. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
  59. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
  60. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
  61. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
  62. package/dist-server/migrations/index.js +7 -1
  63. package/dist-server/migrations/index.js.map +1 -1
  64. package/dist-server/service/index.d.ts +5 -2
  65. package/dist-server/service/index.js +18 -7
  66. package/dist-server/service/index.js.map +1 -1
  67. package/dist-server/service/reference/discovery-result.d.ts +1 -1
  68. package/dist-server/service/reference/discovery-result.js +1 -1
  69. package/dist-server/service/reference/discovery-result.js.map +1 -1
  70. package/dist-server/service/reference/index.d.ts +4 -1
  71. package/dist-server/service/reference/index.js +6 -1
  72. package/dist-server/service/reference/index.js.map +1 -1
  73. package/dist-server/service/reference/reference-adapter.d.ts +184 -3
  74. package/dist-server/service/reference/reference-adapter.js +28 -4
  75. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  76. package/dist-server/service/reference/reference-assessment.d.ts +68 -0
  77. package/dist-server/service/reference/reference-assessment.js +136 -0
  78. package/dist-server/service/reference/reference-assessment.js.map +1 -0
  79. package/dist-server/service/reference/reference-live.d.ts +12 -1
  80. package/dist-server/service/reference/reference-live.js +116 -11
  81. package/dist-server/service/reference/reference-live.js.map +1 -1
  82. package/dist-server/service/reference/reference-master.d.ts +47 -1
  83. package/dist-server/service/reference/reference-master.js +34 -4
  84. package/dist-server/service/reference/reference-master.js.map +1 -1
  85. package/dist-server/service/reference/reference-probe.d.ts +92 -0
  86. package/dist-server/service/reference/reference-probe.js +186 -0
  87. package/dist-server/service/reference/reference-probe.js.map +1 -0
  88. package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
  89. package/dist-server/service/reference/reference-progress-subscription.js +94 -0
  90. package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
  91. package/dist-server/service/reference/reference-progress.d.ts +38 -0
  92. package/dist-server/service/reference/reference-progress.js +71 -0
  93. package/dist-server/service/reference/reference-progress.js.map +1 -0
  94. package/dist-server/service/reference/reference-resolver.js +210 -43
  95. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  96. package/dist-server/service/reference/twin-reference.d.ts +32 -0
  97. package/dist-server/service/reference/twin-reference.js +10 -0
  98. package/dist-server/service/reference/twin-reference.js.map +1 -1
  99. package/dist-server/service/twin-control/twin-control-mutation.js +4 -3
  100. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  101. package/dist-server/service/twin-event/journal-count.d.ts +21 -0
  102. package/dist-server/service/twin-event/journal-count.js +26 -0
  103. package/dist-server/service/twin-event/journal-count.js.map +1 -0
  104. package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
  105. package/dist-server/service/twin-event/twin-event-keys.js +15 -18
  106. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  107. package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
  108. package/dist-server/service/twin-event/twin-event-type.js +18 -1
  109. package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
  110. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  111. package/dist-server/service/twin-event/twin-event.js +9 -1
  112. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  113. package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
  114. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  115. package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
  116. package/dist-server/service/twin-ingest-window/index.js +10 -0
  117. package/dist-server/service/twin-ingest-window/index.js.map +1 -0
  118. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
  119. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
  120. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
  121. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
  122. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
  123. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
  124. package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
  125. package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
  126. package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
  127. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  128. package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
  129. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  130. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
  131. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +72 -2
  132. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  133. package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
  134. package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
  135. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  136. package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
  137. package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
  138. package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
  139. package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
  140. package/dist-server/service/twin-model/epcis-coverage.js +19 -7
  141. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  142. package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
  143. package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
  144. package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
  145. package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
  146. package/dist-server/service/twin-model/isa95-coverage.js +23 -9
  147. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  148. package/dist-server/service/twin-model/item-ref.d.ts +24 -0
  149. package/dist-server/service/twin-model/item-ref.js +88 -0
  150. package/dist-server/service/twin-model/item-ref.js.map +1 -0
  151. package/dist-server/service/twin-model/name-index.d.ts +36 -0
  152. package/dist-server/service/twin-model/name-index.js +116 -0
  153. package/dist-server/service/twin-model/name-index.js.map +1 -0
  154. package/dist-server/service/twin-model/project-structure.js +1 -1
  155. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  156. package/dist-server/service/twin-model/status-tally.d.ts +9 -0
  157. package/dist-server/service/twin-model/status-tally.js +37 -0
  158. package/dist-server/service/twin-model/status-tally.js.map +1 -0
  159. package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
  160. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  161. package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
  162. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  163. package/dist-server/service/twin-model/twin-model-query.js +156 -4
  164. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  165. package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
  166. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  167. package/dist-server/service/twin-readiness/index.d.ts +2 -0
  168. package/dist-server/service/twin-readiness/index.js +6 -0
  169. package/dist-server/service/twin-readiness/index.js.map +1 -0
  170. package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
  171. package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
  172. package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
  173. package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
  174. package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
  175. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  176. package/dist-shared/entity-delta.d.ts +23 -3
  177. package/dist-shared/entity-delta.js +12 -8
  178. package/dist-shared/entity-delta.js.map +1 -1
  179. package/dist-shared/kpi-broadcast.js +1 -1
  180. package/dist-shared/kpi-broadcast.js.map +1 -1
  181. package/dist-shared/touched-items.d.ts +7 -0
  182. package/dist-shared/touched-items.js +80 -0
  183. package/dist-shared/touched-items.js.map +1 -0
  184. package/package.json +7 -7
  185. package/server/engine/canonical-ingest.ts +92 -7
  186. package/server/engine/command-routing.ts +1 -1
  187. package/server/engine/index.ts +4 -0
  188. package/server/engine/ingest-health.ts +704 -0
  189. package/server/engine/integration-coverage.ts +147 -0
  190. package/server/engine/integration-probes.ts +144 -0
  191. package/server/engine/integration-runner.ts +95 -0
  192. package/server/engine/integration-target-profile.ts +103 -0
  193. package/server/engine/kpi-fold.ts +72 -4
  194. package/server/engine/kpi-query.ts +129 -15
  195. package/server/engine/live-feed-registry.ts +2 -1
  196. package/server/engine/load-meter.ts +12 -3
  197. package/server/engine/log.ts +72 -0
  198. package/server/engine/loop-lag.ts +120 -0
  199. package/server/engine/model-gap.ts +168 -0
  200. package/server/engine/restart-policy.ts +4 -4
  201. package/server/engine/runtime-key.ts +1 -1
  202. package/server/engine/stage-path.ts +172 -0
  203. package/server/engine/twin-engine.ts +1135 -162
  204. package/server/index.ts +35 -2
  205. package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
  206. package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
  207. package/server/migrations/index.ts +7 -1
  208. package/server/service/index.ts +11 -0
  209. package/server/service/reference/discovery-result.ts +1 -1
  210. package/server/service/reference/index.ts +6 -1
  211. package/server/service/reference/reference-adapter.ts +197 -5
  212. package/server/service/reference/reference-assessment.ts +215 -0
  213. package/server/service/reference/reference-live.ts +123 -11
  214. package/server/service/reference/reference-master.ts +64 -4
  215. package/server/service/reference/reference-probe.ts +264 -0
  216. package/server/service/reference/reference-progress-subscription.ts +73 -0
  217. package/server/service/reference/reference-progress.ts +95 -0
  218. package/server/service/reference/reference-resolver.ts +200 -12
  219. package/server/service/reference/twin-reference.ts +39 -1
  220. package/server/service/twin-control/twin-control-mutation.ts +4 -3
  221. package/server/service/twin-event/journal-count.ts +53 -0
  222. package/server/service/twin-event/twin-event-keys.ts +16 -1
  223. package/server/service/twin-event/twin-event-type.ts +26 -2
  224. package/server/service/twin-event/twin-event.ts +7 -0
  225. package/server/service/twin-forecast/twin-forecast-query.ts +2 -1
  226. package/server/service/twin-ingest-window/index.ts +7 -0
  227. package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
  228. package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
  229. package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
  230. package/server/service/twin-instance/twin-instance.ts +1 -1
  231. package/server/service/twin-journal/twin-journal-query.ts +59 -5
  232. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +77 -3
  233. package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
  234. package/server/service/twin-model/axis-journal-evidence.ts +57 -0
  235. package/server/service/twin-model/epcis-coverage.ts +2 -8
  236. package/server/service/twin-model/iec61850-coverage.ts +2 -8
  237. package/server/service/twin-model/isa95-coverage.ts +22 -9
  238. package/server/service/twin-model/item-ref.ts +80 -0
  239. package/server/service/twin-model/name-index.ts +94 -0
  240. package/server/service/twin-model/project-structure.ts +1 -1
  241. package/server/service/twin-model/status-tally.ts +37 -0
  242. package/server/service/twin-model/twin-lineage-query.ts +38 -9
  243. package/server/service/twin-model/twin-model-item-query.ts +26 -27
  244. package/server/service/twin-model/twin-model-query.ts +155 -4
  245. package/server/service/twin-model/twin-model-tree-query.ts +7 -0
  246. package/server/service/twin-readiness/index.ts +3 -0
  247. package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
  248. package/server/service/twin-space/twin-space-resolver.ts +5 -4
  249. package/shared/entity-delta.ts +31 -7
  250. package/shared/kpi-broadcast.ts +1 -1
  251. package/shared/touched-items.ts +73 -0
  252. package/test/axis-journal-evidence.test.ts +71 -0
  253. package/test/axis-read.test.ts +101 -1
  254. package/test/boot-resume.test.ts +92 -26
  255. package/test/broadcast-cost-baseline.test.ts +200 -0
  256. package/test/broadcast-period.test.ts +58 -0
  257. package/test/canonical-ingest-vocabularies.test.ts +36 -1
  258. package/test/canonical-quantity-door.test.ts +61 -0
  259. package/test/command-routing.test.ts +1 -1
  260. package/test/declared-location-types.test.ts +89 -0
  261. package/test/discovery-result.test.ts +1 -1
  262. package/test/duration-estimators.test.ts +1 -1
  263. package/test/entity-delta.test.ts +2 -2
  264. package/test/ingest-bench.test.ts +3 -3
  265. package/test/ingest-health-engine.test.ts +248 -0
  266. package/test/ingest-health-wiring.test.ts +119 -0
  267. package/test/ingest-health.test.ts +306 -0
  268. package/test/ingest-history.test.ts +247 -0
  269. package/test/integration-probes.test.ts +103 -0
  270. package/test/integration-runner.test.ts +95 -0
  271. package/test/item-ref.test.ts +78 -0
  272. package/test/journal-read-discipline.test.ts +177 -0
  273. package/test/journal-retention.test.ts +133 -0
  274. package/test/journal-sort-axis.test.ts +142 -0
  275. package/test/journal-write-door.test.ts +110 -0
  276. package/test/journal-write-trend.test.ts +115 -0
  277. package/test/kernel-kind-guard.test.ts +4 -4
  278. package/test/kpi-fold.test.ts +90 -3
  279. package/test/kpi-query-bench.test.ts +2 -2
  280. package/test/lineage-survives-restart.test.ts +17 -1
  281. package/test/live-cursor-wiring.test.ts +87 -0
  282. package/test/live-feed-registry.test.ts +9 -1
  283. package/test/live-kernel-facts.test.ts +7 -7
  284. package/test/live-mirror-parity.test.ts +6 -0
  285. package/test/load-meter.test.ts +29 -15
  286. package/test/local-declarations.test.ts +1 -1
  287. package/test/log-stamp.test.ts +59 -0
  288. package/test/loop-lag.test.ts +82 -0
  289. package/test/model-gap.test.ts +153 -0
  290. package/test/oee-accumulator.test.ts +4 -0
  291. package/test/operational-vocabulary.test.ts +4 -4
  292. package/test/read-failure-visible.test.ts +145 -0
  293. package/test/reference-grounding.test.ts +70 -0
  294. package/test/resolve-ts-siblings.mjs +52 -0
  295. package/test/restart-policy.test.ts +7 -7
  296. package/test/revision-axis.test.ts +93 -0
  297. package/test/runtime-key.test.ts +2 -2
  298. package/test/scale-twin-bench.test.ts +2 -2
  299. package/test/spec-coverage.test.ts +1 -1
  300. package/test/stage-path.test.ts +95 -0
  301. package/test/standard-coverage.test.ts +15 -3
  302. package/test/status-tally.test.ts +55 -0
  303. package/test/structure-revision-db.test.ts +4 -0
  304. package/test/tenant-registry-db.test.ts +1 -1
  305. package/test/time-range.test.ts +152 -0
  306. package/test/touched-items.test.ts +61 -0
  307. package/test/twin-event-keys.test.ts +10 -2
  308. package/test/twin-model-item-db.test.ts +2 -0
  309. package/test/warm-start-seam.test.ts +4 -0
  310. package/test/yield-loop.test.ts +57 -2
  311. package/tsconfig.shared.tsbuildinfo +1 -1
  312. package/tsconfig.tsbuildinfo +1 -1
@@ -1,3 +1,6 @@
1
+ import { pubsub } from '@things-factory/shell'
2
+ import { HOST_STEPS, type ReferenceProgress } from './reference-progress.js'
3
+ import { assessMaster } from './reference-assessment.js'
1
4
  import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'
2
5
 
3
6
  import { ScalarObject, getRepository } from '@things-factory/shell'
@@ -207,8 +210,37 @@ export class TwinReferenceResolver {
207
210
  error: `twin "${instanceId}" does not remember where it came from — it cannot be re-read. Re-create it from its template or reference so the origin is recorded.`
208
211
  }
209
212
 
213
+ /*
214
+ * 진행을 알리는 통로 — 만들 때와 **같은 것**이다. 레퍼런스에서 온 트윈만 알린다(템플릿에서 온
215
+ * 트윈은 원본을 읽지 않으므로 알릴 단계가 없다).
216
+ *
217
+ * 분모는 그 어댑터가 선언한 단계 수다(`masterSteps`) + 호스트 단계. 선언하지 않은 어댑터는
218
+ * `total` 이 없고, 그때 화면은 퍼센트를 만들지 않는다 — 모르는 진행률을 지어내지 않는다.
219
+ */
220
+ const progressSource: string | undefined = origin.kind === 'reference' ? origin.source : undefined
221
+ const refRow = progressSource
222
+ ? await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: progressSource } })
223
+ : null
224
+ const adapterForSteps = refRow ? getAdapter(refRow.adapterType) : undefined
225
+ const declaredSteps = adapterForSteps?.masterSteps?.length
226
+ const stepTotal = declaredSteps === undefined ? undefined : declaredSteps + HOST_STEPS.length
227
+ const siteId = origin.siteId ?? instanceId
228
+ let lastDone = 0
229
+ let lastStep: ReferenceProgress['step'] = (adapterForSteps?.masterSteps?.[0] ?? 'provision') as ReferenceProgress['step']
230
+ const emit = (p: Omit<ReferenceProgress, 'siteCount' | 'siteId' | 'siteIndex'>) => {
231
+ if (!progressSource) return
232
+ lastDone = p.done
233
+ lastStep = p.step
234
+ pubsub.publish('twin-reference-progress', {
235
+ twinReferenceProgress: { ...p, siteId, siteIndex: 0, siteCount: 1, total: p.total ?? stepTotal, source: progressSource }
236
+ })
237
+ }
238
+
210
239
  try {
211
- const master = await this.masterFromOrigin(domainId, origin, instanceId)
240
+ const master = await this.masterFromOrigin(domainId, origin, instanceId, st =>
241
+ emit({ step: st.key, done: st.done, total: stepTotal, detail: st.detail })
242
+ )
243
+ emit({ step: 'provision', done: declaredSteps ?? 0, total: stepTotal })
212
244
  /*
213
245
  * **어느 현장의 트윈이었나를 지킨다** — 다시 읽기는 구조만 새로 그리는 일이고, 현장 소속은 그때
214
246
  * 사람이 고른 것이다. 기억이 없는 옛 트윈은 **지금 있는 현장**을 그대로 쓴다(빈손으로 두면 템플릿
@@ -223,14 +255,51 @@ export class TwinReferenceResolver {
223
255
  keepSpace ? { spaceId: keepSpace } : undefined,
224
256
  context.state.user
225
257
  )
258
+ emit({ step: 'start', done: (declaredSteps ?? 0) + 1, total: stepTotal })
259
+ /*
260
+ * **총평도 새로 남긴다.** 다시 읽었는데 총평이 옛것이면 화면이 지난 읽기를 현재로 보여 준다 —
261
+ * 그 표를 보고 사람이 원본을 고치러 간다.
262
+ */
263
+ if (refRow && progressSource) {
264
+ const prevSpec = (refRow.scopeSpec as any) ?? {}
265
+ await getRepository(TwinReference).update(
266
+ { id: refRow.id },
267
+ {
268
+ scopeSpec: { ...prevSpec, assessments: { ...(prevSpec.assessments ?? {}), [siteId]: assessMaster(master, siteId) } }
269
+ } as any
270
+ )
271
+ }
226
272
  return { ok: true, instanceId: res.instanceId, spaceId: res.spaceId, warnings: res.warnings }
227
273
  } catch (e: any) {
274
+ /* 막힌 자리를 그대로 알린다 — 「끝낸 수」의 다음 단계다(만들 때와 같은 규칙). */
275
+ emit({
276
+ step: (adapterForSteps?.masterSteps?.[lastDone] ?? lastStep) as ReferenceProgress['step'],
277
+ done: lastDone,
278
+ total: stepTotal,
279
+ failed: true,
280
+ error: e?.message ?? String(e)
281
+ })
228
282
  return refusal(context, e, 'resync failed')
229
283
  }
230
284
  }
231
285
 
232
286
  /** 기억해 둔 원천에서 마스터를 다시 얻는다 — 어댑터든 템플릿이든 이 한 곳에서 갈린다. */
233
- private async masterFromOrigin(domainId: string, origin: any, instanceId: string): Promise<any> {
287
+ private async masterFromOrigin(
288
+ domainId: string,
289
+ origin: any,
290
+ instanceId: string,
291
+ /*
292
+ * **다시 읽기도 진행을 보여야 한다** (2026-08-22).
293
+ *
294
+ * 만들 때는 8단계가 화면에 흐르는데 다시 읽을 때는 아무것도 없었다 — 사용자가 버튼을 누르고
295
+ * 「진행되고 있는지 확인할 수가 없다」고 했다. 실제로 chef 는 원본 읽기가 14~21초이고 그 사이
296
+ * 화면에 아무 변화가 없다.
297
+ *
298
+ * 통로는 만들 때와 **같은 것**이다(`twin-reference-progress`). 화면도 같은 표시를 쓴다 —
299
+ * 두 벌을 만들면 한쪽만 고쳐지고 사람은 같은 일이 다르게 보이는 것을 결함으로 읽는다.
300
+ */
301
+ onStep?: (s: { key: any; done: number; total?: number; detail?: string }) => void
302
+ ): Promise<any> {
234
303
  if (origin.kind === 'template') {
235
304
  const tpl = getTemplate(origin.templateId)
236
305
  if (!tpl) throw new Error(`unknown template: ${origin.templateId} — this twin cannot be rebuilt`)
@@ -267,7 +336,7 @@ export class TwinReferenceResolver {
267
336
  * 다른 현장의 구조를 이 트윈에 덮는 것은 오류가 아니라 조용히 틀린 공장으로 나타난다.
268
337
  */
269
338
  const picked = pickOriginSite(sites, origin)
270
- if (picked.site) return { ...(await adapter.fetchMaster(cfg, picked.site)), origin }
339
+ if (picked.site) return { ...(await adapter.fetchMaster(cfg, picked.site, onStep)), origin }
271
340
  if (picked.reason === 'site-gone')
272
341
  throw new Error(
273
342
  `reference "${origin.source}" no longer reports site "${origin.siteId}" — refusing to overwrite this twin with a different site`
@@ -368,8 +437,19 @@ export class TwinReferenceResolver {
368
437
  async twinReferences(@Ctx() context: ResolverContext): Promise<any[]> {
369
438
  const domainId = context.state.domain.id
370
439
  const rows = await getRepository(TwinReference).find({ where: { domain: { id: domainId } } })
371
- // provenance 라이브 대조 — 지워진 트윈은 producedCount 에서 자동 제외(상태 동기의 lazy 방식).
372
- const liveIds = new Set((await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })).map(i => i.instanceId))
440
+ /*
441
+ * provenance 라이브 대조 지워진 트윈은 producedCount 에서 자동 제외(상태 동기의 lazy 방식).
442
+ *
443
+ * **id 만 읽는다** (2026-08-21). 예전에는 인스턴스 행 전체를 읽었고, 그 행에는 **모델 JSON** 이
444
+ * 들어 있다(자리 수백 개 + 발자국 좌표). 그래서 이 목록을 여는 데 수 메가바이트를 읽고 파싱했고,
445
+ * 화면은 그동안 「아직 연결된 시스템이 없습니다」를 보이고 있었다 — 사용자에게는 「없다」는 답으로
446
+ * 읽힌다. 여기서 필요한 것은 살아 있는 id 집합 하나다.
447
+ */
448
+ const liveIds = new Set(
449
+ (await getRepository(TwinInstance).find({ where: { domain: { id: domainId } }, select: ['instanceId'] })).map(
450
+ i => i.instanceId
451
+ )
452
+ )
373
453
  return rows.map(r => {
374
454
  const produced = (((r.scopeSpec as any)?.produced ?? []) as any[]).filter(p => liveIds.has(p.instanceId))
375
455
  return {
@@ -445,15 +525,37 @@ export class TwinReferenceResolver {
445
525
  const secretKeys = new Set((getAdapter(r.adapterType)?.meta?.configSchema ?? []).filter(f => f.secret).map(f => f.key))
446
526
  const cfg: any = {}
447
527
  for (const [k, v] of Object.entries((r.connectionConfig as any) ?? {})) cfg[k] = secretKeys.has(k) ? (v ? '••••••' : '') : v
528
+ /*
529
+ * **이 연결이 만든 트윈 id 를 함께 낸다.** 상세 화면이 「이 연결이 잘 동기화되고 있나」를 물으려면
530
+ * 어느 트윈을 물어야 하는지 알아야 한다. 예전에는 개수(`producedCount`)만 있어서 화면이 id 를
531
+ * 알 방법이 없었다.
532
+ */
533
+ const producedInstanceIds = [
534
+ ...new Set(
535
+ ((r.scopeSpec as any)?.produced ?? [])
536
+ .map((p: any) => p?.instanceId)
537
+ .filter((x: any): x is string => typeof x === 'string' && !!x)
538
+ )
539
+ ]
448
540
  return {
449
541
  source: r.source, system: r.system, siteName: r.siteName, description: r.description,
450
- adapterType: r.adapterType, connectionConfig: cfg, status: r.status, lastSyncedAt: r.lastSyncedAt, lastError: r.lastError
542
+ adapterType: r.adapterType, connectionConfig: cfg, status: r.status, lastSyncedAt: r.lastSyncedAt, lastError: r.lastError,
543
+ producedInstanceIds
451
544
  }
452
545
  }
453
546
 
454
- @Query(returns => [ScalarObject], { description: 'List reference connector metadata (label, domain, config schema, availability) for the connect UI.' })
547
+ @Query(returns => [ScalarObject], { description: 'List reference connector metadata (label, domain, config schema, availability, capabilities) for the connect UI.' })
455
548
  referenceAdapters(): any[] {
456
- return listAdapters().map(a => ({ type: a.type, ...(a.meta ?? {}) }))
549
+ /*
550
+ * **능력을 함께 낸다** (2026-08-21).
551
+ *
552
+ * `capabilities` 는 어댑터 뿌리에 있고 `meta` 에는 없었다. 그래서 연결 화면이 「이 원본이 라이브를
553
+ * 낼 수 있나」를 알 수 없었고, 트윈을 만들 때 `live` 를 말하지 못해 **재기동 정책이 없다고 거절**됐다
554
+ * (실 연동에서 그것에 막혔다 — 미러는 정의상 `resync` 이므로 `live` 하나로 정해진다).
555
+ *
556
+ * 화면이 그 사실을 알아야 정책을 사람에게 물을지 스스로 정할지 가를 수 있다.
557
+ */
558
+ return listAdapters().map(a => ({ type: a.type, ...(a.meta ?? {}), capabilities: a.capabilities ?? [] }))
457
559
  }
458
560
 
459
561
  @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
@@ -601,9 +703,60 @@ export class TwinReferenceResolver {
601
703
  if (refusal) return { ok: false, error: refusal.message, errorCode: refusal.code, errorParams: refusal.params }
602
704
  const instances: any[] = []
603
705
  const failed: any[] = []
604
- for (const site of sites) {
706
+ /*
707
+ * **진행을 구독으로 흘린다** (2026-08-21).
708
+ *
709
+ * 마스터 읽기는 원본에 여러 질의를 보내는 긴 작업이고, 화면은 그 사이 아무것도 알 수 없었다.
710
+ * 단계를 아는 것은 어댑터뿐이므로 어댑터가 말하고(표준 낱말), 호스트가 자기 두 단계(생성·기동)를
711
+ * 더해 흘린다.
712
+ *
713
+ * **분모는 어댑터가 선언한 것**이다(`masterSteps`). 선언하지 않은 어댑터는 `total` 이 없고, 그때
714
+ * 화면은 퍼센트를 만들지 않는다 — 모르는 진행률을 지어내지 않는다.
715
+ */
716
+ const declared = adapter.masterSteps?.length
717
+ const stepTotal = declared === undefined ? undefined : declared + HOST_STEPS.length
718
+ const emit = (p: Omit<ReferenceProgress, 'siteCount'>) =>
719
+ pubsub.publish('twin-reference-progress', {
720
+ twinReferenceProgress: { ...p, siteCount: sites.length, total: p.total ?? stepTotal, source }
721
+ })
722
+
723
+ /*
724
+ * **읽은 뒤의 총평을 사이트마다 남긴다** (2026-08-22).
725
+ *
726
+ * 총평은 읽는 순간에만 있었다 — 대화상자를 닫으면 사라지고, 다시 보려면 같은 읽기를 또 해야 했다.
727
+ * 그런데 사람이 그것을 묻는 자리는 「모델 살펴보기」다(트윈을 보다가 「이건 왜 비었나」를 묻는다).
728
+ * 그래서 레퍼런스 행에 남겨 그 화면이 언제든 읽게 한다.
729
+ *
730
+ * 사실만 담는다(`assessMaster`) — 문장은 화면이 만들고, 더 알고 싶을 때 AI 가 이 사실만 근거로
731
+ * 서술한다. 다시 읽으면 그 사이트의 총평이 새것으로 바뀐다(지난 읽기의 총평을 현재로 보이게
732
+ * 두지 않는다).
733
+ */
734
+ const assessments: Record<string, unknown> = {}
735
+
736
+ for (const [siteIndex, site] of sites.entries()) {
737
+ /*
738
+ * **실패를 실제로 막힌 단계에 적는다** (2026-08-22).
739
+ *
740
+ * 예전에는 실패를 무조건 `provision` 으로 적었다. 그래서 원본을 읽다 끊긴 것도 화면에는
741
+ * 「트윈 생성에서 실패」로 떴다 — 사람이 원인을 엉뚱한 자리에서 찾는다(실제로 그렇게 됐다).
742
+ *
743
+ * 규약이 답을 준다: `done` 은 **끝낸 단계 수**다(`ReferenceProgress.done`). 그러면 지금 막힌 것은
744
+ * 그 다음 단계, 즉 `masterSteps[done]` 이다. 그 표를 넘어서면(호스트 단계) 마지막으로 알린 것을 쓴다.
745
+ *
746
+ * 이 규칙은 두 가지 보고 방식에 함께 맞는다 — 단계 **시작**에 알리는 어댑터(`done` 이 아직 0)와
747
+ * 단계 **완료**에 알리는 어댑터(`done` 이 1 늘어난다) 모두 「끝낸 수」만 지키면 된다.
748
+ */
749
+ let lastDone = 0
750
+ let lastStep: ReferenceProgress['step'] = (adapter.masterSteps?.[0] ?? 'provision') as ReferenceProgress['step']
751
+ const track = (p: Omit<ReferenceProgress, 'siteCount'>) => {
752
+ lastDone = p.done
753
+ lastStep = p.step
754
+ emit(p)
755
+ }
605
756
  try {
606
- const master = await adapter.fetchMaster(cfg, site)
757
+ const master = await adapter.fetchMaster(cfg, site, st =>
758
+ track({ siteId: site.siteId, siteIndex, step: st.key, done: st.done, total: stepTotal, detail: st.detail })
759
+ )
607
760
  /* 어느 레퍼런스의 **어느 사이트**에서 왔는지 남긴다 — 다시 읽기가 같은 어댑터·같은 사이트로 돌아오려면
608
761
  사이트 정체가 있어야 한다(`source` 는 참조 이름이라 사이트 전부에 같다). */
609
762
  /*
@@ -611,7 +764,9 @@ export class TwinReferenceResolver {
611
764
  * 미러는 정의상 `resync` 다(외부가 진실이므로 우리 과거를 이어 붙이지 않는다). 시뮬로 띄우려면
612
765
  * 스스로 선언해야 한다 — 그 자리에서 우리가 고르면 재기동마다 이력을 지우는 트윈이 조용히 생긴다.
613
766
  */
767
+ assessments[site.siteId] = assessMaster(master, site.siteId)
614
768
  const policy = readRestartPolicy(restartPolicy ?? (live ? 'resync' : undefined), `importTwinReference("${source}")`)
769
+ track({ siteId: site.siteId, siteIndex, step: 'provision', done: declared ?? 0, total: stepTotal })
615
770
  const res = await TwinEngine.ingestMaster(
616
771
  domainId,
617
772
  { ...master, origin: { kind: 'reference', source, siteId: site.siteId, ...(spaceId ? { spaceId } : {}) } },
@@ -619,15 +774,27 @@ export class TwinReferenceResolver {
619
774
  { spaceId },
620
775
  context.state.user
621
776
  )
777
+ track({ siteId: site.siteId, siteIndex, step: 'start', done: (declared ?? 0) + 1, total: stepTotal })
622
778
  if (start !== false) {
623
779
  // live=true + 어댑터가 openLiveFeed 지원 → mirror(실 이벤트 미러). 아니면 sim 기동(fallback).
624
- const wentLive = live ? await startReferenceLiveFeed(domainId, ref.adapterType, cfg, site, res.instanceId) : false
780
+ /* 읽기 커서를 저장할 자리를 함께 넘긴다 레퍼런스의 이름은 호스트가 붙이는 것이고
781
+ 커넥터는 그것을 알 수 없다(§`startReferenceLiveFeed`). */
782
+ const wentLive = live
783
+ ? await startReferenceLiveFeed(domainId, ref.adapterType, cfg, site, res.instanceId, ref.source)
784
+ : false
625
785
  if (!wentLive) await TwinEngine.startFromRegistry(domainId, res.instanceId)
626
786
  instances.push({ siteId: site.siteId, instanceId: res.instanceId, spaceId: res.spaceId, mode: wentLive ? 'live' : 'sim', warnings: res.warnings })
627
787
  } else {
628
788
  instances.push({ siteId: site.siteId, instanceId: res.instanceId, spaceId: res.spaceId, mode: 'provisioned', warnings: res.warnings })
629
789
  }
630
790
  } catch (e: any) {
791
+ /*
792
+ * **실패한 단계를 그대로 알린다.** 화면이 진행 표시를 지우지 않고 그 자리를 붉게 두어야
793
+ * 사람이 「어디까지 갔는지」를 안다. 막힌 자리는 「끝낸 수」의 다음 단계다(위 주석).
794
+ */
795
+ const at = (adapter.masterSteps?.[lastDone] ?? lastStep) as ReferenceProgress['step']
796
+ emit({ siteId: site.siteId, siteIndex, step: at, done: lastDone, total: stepTotal, failed: true, error: e?.message ?? String(e) })
797
+
631
798
  failed.push({ siteId: site.siteId, error: e?.message ?? 'failed' })
632
799
  }
633
800
  }
@@ -639,6 +806,10 @@ export class TwinReferenceResolver {
639
806
  {
640
807
  status: 'connected',
641
808
  lastSyncedAt: new Date().toISOString(),
809
+ /* 못 옮긴 값을 **세어 남긴다**. 아무 인스턴스도 보고하지 않았으면 `null` — 「없었다」로 위장하지 않는다. */
810
+ lastWarningCount: instances.some((i: any) => Array.isArray(i.warnings))
811
+ ? instances.reduce((n: number, i: any) => n + (Array.isArray(i.warnings) ? i.warnings.length : 0), 0)
812
+ : null,
642
813
  /*
643
814
  * **영어 canonical 로 남긴다.** 이 값은 DB 에 저장되고 연결 화면이 그대로 뿌린다 — 예전에는
644
815
  * 한글 산문(`N개 사이트 실패`)이라 다섯 언어 화면에 한글이 새어 나갔다. 어댑터가 주는 사유도
@@ -646,7 +817,24 @@ export class TwinReferenceResolver {
646
817
  * 어느 사이트가 왜 실패했는지는 `failed[]` 가 답한다 — 화면이 그것을 보여 준다.
647
818
  */
648
819
  lastError: failed.length ? `${failed.length} of ${sites.length} site(s) failed: ${failed.map((f: any) => f.siteId).join(', ')}` : null,
649
- scopeSpec: { ...((ref.scopeSpec as any) ?? {}), produced: [...prev, ...producedNew] }
820
+ /*
821
+ * ── 실패 사유를 **남긴다** (2026-08-21) ────────────────────────────────
822
+ *
823
+ * 사유는 대화상자에만 있었다. 닫으면 사라지고, 다시 보려면 같은 실패를 다시 만들어야 했다 —
824
+ * 실제로 「승화푸드」 연결이 그 상태였다(요약 한 줄만 남고 원인은 어디에도 없었다).
825
+ *
826
+ * `scopeSpec` 은 이미 이 레퍼런스의 부속 사실을 담는 자리이므로(`produced`) 스키마를 늘리지 않고
827
+ * 여기 적는다. 성공한 회차에는 **비운다** — 지나간 실패를 현재 상태처럼 보이게 두지 않는다.
828
+ */
829
+ scopeSpec: {
830
+ ...((ref.scopeSpec as any) ?? {}),
831
+ produced: [...prev, ...producedNew],
832
+ /* 이번에 읽은 사이트의 총평만 갈아 끼운다 — 읽지 않은 사이트의 것은 그대로 둔다. */
833
+ assessments: { ...(((ref.scopeSpec as any) ?? {}).assessments ?? {}), ...assessments },
834
+ ...(failed.length
835
+ ? { lastFailures: failed.map((f: any) => ({ siteId: f.siteId, error: f.error, at: new Date().toISOString() })) }
836
+ : { lastFailures: [] })
837
+ }
650
838
  } as any
651
839
  )
652
840
  return { ok: instances.length > 0, instances, failed }
@@ -1,5 +1,5 @@
1
1
  import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
2
- import { ObjectType, Field, ID } from 'type-graphql'
2
+ import { ObjectType, Field, ID, Int } from 'type-graphql'
3
3
 
4
4
  import { Domain, ScalarObject } from '@things-factory/shell'
5
5
 
@@ -70,6 +70,44 @@ export class TwinReference {
70
70
  @Field({ nullable: true, description: 'Last connection/sync error message, if any.' })
71
71
  lastError?: string
72
72
 
73
+ /**
74
+ * 마지막 동기에서 **못 옮긴 값의 수**. `null` = 세지 않았다(0 과 다르다).
75
+ *
76
+ * 세지 않은 구조 인제스트는 도크를 랙으로 바꿔 놓고도 조용히 성공한다. 그 사실을 사후에 물을 수
77
+ * 있어야 단계 판정이 「구조는 됐다」를 말할 수 있다 — 없는 동안 판정은 늘 `unmeasured` 였다.
78
+ * 기본값을 두지 않는다: 0 을 기본으로 깔면 「경고 없었다」와 「세지 않았다」가 같아진다.
79
+ */
80
+ @Column({ type: 'int', nullable: true })
81
+ @Field(type => Int, { nullable: true, description: 'Number of values the last sync could not carry over (mapping fallbacks). Null means they were not counted at all, which is different from zero.' })
82
+ lastWarningCount?: number
83
+
84
+ /**
85
+ * **어디까지 읽었나** — 라이브 피드의 읽기 커서(§`LiveFeedContinuity`).
86
+ *
87
+ * ── 왜 저장하나 (2026-08-23 실측) ──────────────────────────────────────────
88
+ * 어댑터가 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들고 있었다. 그래서 **재기동마다
89
+ * 미러의 과거가 잘렸다** — 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는 것이 「원본의 사실」이
90
+ * 아니라 「창의 함수」였고, 그 사실을 아무도 말하지 않았다.
91
+ *
92
+ * 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`): 「원천이 애초에 다시 말해 주지 않는 축」은
93
+ * 재기동 연속성으로 이어받는다. 「우리가 어디까지 읽었나」가 정확히 그 성질이므로 **어댑터의 사물함이
94
+ * 아니라 이 층**에 있다 — 원본이 늘 때마다 저장 기제가 늘고 그중 하나가 조용히 다르게 동작하지 않게.
95
+ *
96
+ * ── 왜 캐시가 아니라 표인가 ────────────────────────────────────────────────
97
+ * 만료되면 창이 다시 미끄러지고, 그 손실은 오류 없이 조용하다. 스냅샷 체크포인트와 성질이 다르다
98
+ * (그쪽은 잃어도 원천이 정정해 준다 — 이 값은 **잃으면 원천에 묻지 않게 된다**).
99
+ *
100
+ * 모양은 `{ streams: { [흐름]: { since?, seen[] } }, firstAttachedAt? }` 다. **흐름 이름은 어댑터가
101
+ * 정한다** — 원본마다 흐름 수와 뜻이 다르므로 이 층은 열쇠로만 다룬다. `simple-json` 이라 드라이버
102
+ * 다섯을 그대로 지난다.
103
+ *
104
+ * `firstAttachedAt` 은 「언제부터 아는가」다. 커널은 상한만 안다(`nowTime` = 마지막으로 들은 시각).
105
+ * 둘이 함께 「이 트윈이 아는 구간」이고, 화면이 수를 보일 때 그 구간을 말해야 한다.
106
+ */
107
+ @Column({ type: 'simple-json', nullable: true })
108
+ @Field(type => ScalarObject, { nullable: true, description: 'Live feed read cursor carried across restarts, keyed by adapter-defined stream. Null means the feed has never attached, and the next attach decides its first window.' })
109
+ liveCursor?: any
110
+
73
111
  @CreateDateColumn()
74
112
  @Field({ nullable: true, description: 'Timestamp when the reference was first created.' })
75
113
  createdAt?: Date
@@ -1,3 +1,4 @@
1
+ import { twinError } from '../../engine/log.js'
1
2
  import { Arg, Ctx, Mutation, Query, Resolver, Directive } from 'type-graphql'
2
3
 
3
4
  import { validateScenario } from '@operato/twin-kernel'
@@ -42,7 +43,7 @@ export class TwinControlMutation {
42
43
  const ack = await TwinEngine.dispatchCommand(domainId, instanceId, command)
43
44
  /* 라이브 구조 변이(resource.add)는 저장 model 에도 반영 — 프로비저닝 편집기·재기동이 추가분을 잃지 않게. */
44
45
  if (ack?.accepted && command?.type === 'resource.add') {
45
- await TwinEngine.syncBoardEquipment(domainId, instanceId).catch(err => console.error('twin syncBoardEquipment fail', err))
46
+ await TwinEngine.syncBoardEquipment(domainId, instanceId).catch(err => twinError('twin syncBoardEquipment fail', err))
46
47
  }
47
48
  /*
48
49
  * 감사 기록 — 승인 게이트가 앞을 막고, 이 줄이 뒤를 설명한다. 둘 다 있어야 "사람 손을 거쳤다" 를
@@ -56,7 +57,7 @@ export class TwinControlMutation {
56
57
  const repo = getRepository(TwinAuditEvent)
57
58
  await repo.save(repo.create({ domain: context.state.domain, instanceId, ...audit }))
58
59
  } catch (err) {
59
- console.error(`[twin-control] command audit not recorded for "${instanceId}"`, err)
60
+ twinError(`[twin-control] command audit not recorded for "${instanceId}"`, err)
60
61
  }
61
62
 
62
63
  /* 거절 사유는 **언어 중립 코드**로 함께 돌려준다 — 화면이 `twin.cmderr.<code>` 로 사람 말을 만든다. */
@@ -132,7 +133,7 @@ export class TwinControlMutation {
132
133
  await repo.save(repo.create({ domain: context.state.domain, instanceId, ...audit }))
133
134
  } catch (err) {
134
135
  /* 기록이 실패해도 결과는 돌려준다 — 다만 삼키지 않는다(「기록 없음」과 「일 없음」을 구별해야 한다). */
135
- console.error(`[twin-control] source control audit not recorded for "${instanceId}"`, err)
136
+ twinError(`[twin-control] source control audit not recorded for "${instanceId}"`, err)
136
137
  }
137
138
  return result
138
139
  }
@@ -0,0 +1,53 @@
1
+ /*
2
+ * 저널을 **상한까지만 센다** — 세는 것도 읽는 것이다 (2026-08-22).
3
+ *
4
+ * ── 왜 (실측) ───────────────────────────────────────────────────────────────
5
+ * 조건에 맞는 행을 전부 세는 질의는 행을 실어 오지 않아도 비싸다. 개발 저널에서 트윈 하나가 358만
6
+ * 행이었고 그 한 번이 **5.32초**였다. 색인만 상한까지 읽으면 같은 조건에서 **0.04초**다(130배).
7
+ *
8
+ * 그 5초 동안 이벤트 루프는 다른 요청을 처리하지 못한다 — 이력 화면 한 번, 준비도 화면 한 번이
9
+ * 사용자에게는 「눌렀는데 아무 반응이 없다」로 나타난다. 준비도 화면은 그런 세기를 **넷** 했다.
10
+ *
11
+ * ── 규율 ────────────────────────────────────────────────────────────────────
12
+ * 세는 목적이 둘 중 하나다.
13
+ * ① **판정** — 「한 건이라도 있나」. 그때는 하나만 찾으면 끝이다(`anyJournalRow`).
14
+ * ② **표시** — 사람에게 수를 보인다. 그때는 상한까지 세고, 넘으면 **모른다고 말한다**(`countUpTo`).
15
+ *
16
+ * 상한을 넘었을 때 상한값을 전체로 적으면 화면이 거짓을 말한다. 그래서 `capped` 를 함께 낸다 —
17
+ * 없는 것과 모르는 것을 가르는 이 프로젝트의 규율이 세기에도 같이 적용된다.
18
+ */
19
+ import type { ObjectLiteral, Repository } from 'typeorm'
20
+
21
+ /** 표시용 세기의 기본 상한 — 화면이 한 번에 보여 줄 범위를 훨씬 넘는 값. */
22
+ export const JOURNAL_COUNT_CAP = 10_000
23
+
24
+ export interface CappedCount {
25
+ /** 센 수. `capped` 면 상한까지만 센 값이므로 **전체가 아니다**. */
26
+ count: number
27
+ /** 상한을 넘었나 — 그때 전체 수는 모르는 것이 사실이다. */
28
+ capped: boolean
29
+ }
30
+
31
+ /**
32
+ * 상한까지만 세기 — 키 컬럼만 읽으므로 행 본문을 만들지 않는다.
33
+ *
34
+ * `count()` 를 쓰지 않는 이유: 그것은 상한을 받지 않는다(조건에 맞는 전부를 센다).
35
+ */
36
+ export async function countUpTo<T extends ObjectLiteral>(
37
+ repo: Repository<T>,
38
+ where: any,
39
+ cap = JOURNAL_COUNT_CAP
40
+ ): Promise<CappedCount> {
41
+ const rows = await repo.find({ where, select: ['id'] as any, take: cap + 1 })
42
+ return rows.length > cap ? { count: cap, capped: true } : { count: rows.length, capped: false }
43
+ }
44
+
45
+ /**
46
+ * 한 건이라도 있나 — **판정에는 수가 필요 없다.**
47
+ *
48
+ * 예전에는 전부 센 뒤 `> 0` 을 봤다. 그 답을 얻는 데 358만 행을 세고 있었다.
49
+ */
50
+ export async function anyJournalRow<T extends ObjectLiteral>(repo: Repository<T>, where: any): Promise<boolean> {
51
+ const rows = await repo.find({ where, select: ['id'] as any, take: 1 })
52
+ return rows.length > 0
53
+ }
@@ -1,3 +1,4 @@
1
+ import { twinWarn } from '../../engine/log.js'
1
2
  /*
2
3
  * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.
3
4
  *
@@ -18,6 +19,7 @@
18
19
 
19
20
  /** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */
20
21
  export interface TwinEventKeys {
22
+ action?: string
21
23
  bizStep?: string
22
24
  epc?: string
23
25
  orderId?: string
@@ -37,7 +39,7 @@ function clip(v: unknown, field: string): string | undefined {
37
39
  const s = String(v)
38
40
  if (!s) return undefined
39
41
  if (s.length <= MAX_KEY) return s
40
- console.warn(
42
+ twinWarn(
41
43
  `[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +
42
44
  `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`
43
45
  )
@@ -90,9 +92,22 @@ export function equipmentIdOf(envelope: any): string | undefined {
90
92
  return d.moverId ?? undefined
91
93
  }
92
94
 
95
+ /**
96
+ * EPCIS 행위 — `ADD`·`OBSERVE`·`DELETE`. 세 값이 아니면 `undefined`(원천의 잡값을 색인에 넣지 않는다).
97
+ *
98
+ * 이 키는 검색 축이 아니라 **판별식**이다: 되풀어 읽는 스냅샷은 원리적으로 `DELETE` 를 못 낸다.
99
+ * 그래서 「이 트윈이 반출을 한 번이라도 접었는가」가 진짜 delta 피드의 증거인데, payload 안에 있는
100
+ * 동안에는 5개 드라이버 어디에서도 그 질문을 할 수 없었다. 운영 델타에는 행위가 없다 — 비운다.
101
+ */
102
+ export function actionOf(envelope: any): string | undefined {
103
+ const a = (envelope?.data ?? envelope ?? {}).action
104
+ return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined
105
+ }
106
+
93
107
  /** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */
94
108
  export function twinEventKeys(envelope: any): TwinEventKeys {
95
109
  return {
110
+ action: actionOf(envelope),
96
111
  bizStep: clip(bizStepOf(envelope), 'bizStep'),
97
112
  epc: clip(epcOf(envelope), 'epc'),
98
113
  orderId: clip(orderOf(envelope), 'orderId'),
@@ -14,8 +14,32 @@ export class TwinEventList {
14
14
  @Field(type => [TwinEvent], { description: 'The events on this page, ordered by the requested sorting (revision descending by default).' })
15
15
  items: TwinEvent[]
16
16
 
17
- @Field(type => Int, { description: 'Total number of events matching the filters, ignoring pagination. Lets the caller show an honest "shown of total" count instead of silently truncating.' })
18
- total: number
17
+ /*
18
+ * 전체 수 — **상한을 넘으면 비운다.**
19
+ *
20
+ * 세는 데도 값이 든다(2026-08-22 실측: 저널 358만 행인 트윈에서 전량 세기 5.32초 · 상한 1만까지
21
+ * 0.04초). 그래서 상한까지만 세고, 넘으면 「모른다」로 둔다. 그때 상한값을 전체로 적으면 화면이
22
+ * 거짓을 말한다 — 없는 것과 모르는 것을 가르는 것이 이 필드의 규율이다.
23
+ */
24
+ @Field(type => Int, {
25
+ nullable: true,
26
+ description:
27
+ 'Total number of events matching the filters, ignoring pagination. Lets the caller show an honest "shown of total" count instead of silently truncating. NULL when the server stopped counting at countCap: the exact total is then unknown, and capped says so — do not render the cap as if it were the total.'
28
+ })
29
+ total?: number
30
+
31
+ @Field({
32
+ nullable: true,
33
+ description:
34
+ 'True when there are more matching events than countCap, so total could not be established. The caller should say "first N shown" and offer narrowing rather than implying it received everything.'
35
+ })
36
+ capped?: boolean
37
+
38
+ @Field(type => Int, {
39
+ nullable: true,
40
+ description: 'The counting limit the server applied. Reported so the screen can say how far the count is trustworthy instead of guessing.'
41
+ })
42
+ countCap?: number
19
43
 
20
44
  /*
21
45
  * 다음 페이지 커서. 저널은 **머리에 계속 쌓이는 목록**이라 offset 으로 뒤를 읽으면
@@ -34,6 +34,8 @@ import { Domain, ScalarObject } from '@things-factory/shell'
34
34
  @Index('ix_twin_event_4', (e: TwinEvent) => [e.domain, e.instanceId, e.orderId], { unique: false })
35
35
  /* ix_5 — "이 지시가 무엇을 일으켰나". 감사 기록의 commandId 로 저널을 다시 계산하는 축. */
36
36
  @Index('ix_twin_event_5', (e: TwinEvent) => [e.domain, e.correlationId], { unique: false })
37
+ /* ix_6 — "반출을 한 번이라도 접었나". 없으면 그 질문이 인스턴스 저널 전량 주사가 된다. */
38
+ @Index('ix_twin_event_6', (e: TwinEvent) => [e.domain, e.instanceId, e.action], { unique: false })
37
39
  @ObjectType({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' })
38
40
  export class TwinEvent {
39
41
  @PrimaryGeneratedColumn('uuid')
@@ -132,6 +134,11 @@ export class TwinEvent {
132
134
  @Field({ nullable: true, description: 'Id of the command that caused this fact (kernel correlation id; defaults to the commandId). Empty for facts that simulation time produced rather than a command.' })
133
135
  correlationId?: string
134
136
 
137
+ /* 옛 행은 비어 있다 — 승격 전에 기록된 것이라 **모른다**(0 이나 OBSERVE 로 위장하지 않는다). */
138
+ @Column({ length: 16, nullable: true })
139
+ @Field({ nullable: true, description: 'EPCIS action (ADD / OBSERVE / DELETE), promoted from the payload. Empty for operational deltas, and for rows journaled before this column existed.' })
140
+ action?: string
141
+
135
142
  @Column({ length: 255, nullable: true })
136
143
  @Field({ nullable: true, description: 'Business step (CBV bizStep tail), promoted from the payload for indexed filtering.' })
137
144
  bizStep?: string
@@ -1,3 +1,4 @@
1
+ import { twinLog } from '../../engine/log.js'
1
2
  import { Arg, Ctx, Int, Mutation, Query, Resolver, Directive} from 'type-graphql'
2
3
 
3
4
  import { getRepository, ScalarObject } from '@things-factory/shell'
@@ -193,7 +194,7 @@ export class TwinForecastQuery {
193
194
  const basis = await TwinEngine.modelBasis(domainId, instanceId)
194
195
  const stale = !!stored && modelBasisChanged(stored.basis, basis)
195
196
  if (stale) {
196
- console.log(
197
+ twinLog(
197
198
  `[twin-forecast] "${instanceId}": calibration not applied — model changed since it was learned.` +
198
199
  ` learned on: ${describeModelBasis(stored!.basis)} / now: ${describeModelBasis(basis)}`
199
200
  )
@@ -0,0 +1,7 @@
1
+ import { TwinIngestWindow } from './twin-ingest-window.js'
2
+ import { TwinIngestWindowQuery } from './twin-ingest-window-query.js'
3
+
4
+ export const entities = [TwinIngestWindow]
5
+ export const resolvers = [TwinIngestWindowQuery]
6
+
7
+ export { installIngestWindowWriter } from './twin-ingest-window-writer.js'