@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.
- package/dist-server/engine/canonical-ingest.d.ts +57 -2
- package/dist-server/engine/canonical-ingest.js +43 -2
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/command-routing.js +1 -1
- package/dist-server/engine/command-routing.js.map +1 -1
- package/dist-server/engine/index.d.ts +3 -0
- package/dist-server/engine/index.js +4 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +335 -0
- package/dist-server/engine/ingest-health.js +434 -0
- package/dist-server/engine/ingest-health.js.map +1 -0
- package/dist-server/engine/integration-coverage.d.ts +76 -0
- package/dist-server/engine/integration-coverage.js +73 -0
- package/dist-server/engine/integration-coverage.js.map +1 -0
- package/dist-server/engine/integration-probes.d.ts +65 -0
- package/dist-server/engine/integration-probes.js +100 -0
- package/dist-server/engine/integration-probes.js.map +1 -0
- package/dist-server/engine/integration-runner.d.ts +45 -0
- package/dist-server/engine/integration-runner.js +59 -0
- package/dist-server/engine/integration-runner.js.map +1 -0
- package/dist-server/engine/integration-target-profile.d.ts +57 -0
- package/dist-server/engine/integration-target-profile.js +79 -0
- package/dist-server/engine/integration-target-profile.js.map +1 -0
- package/dist-server/engine/kpi-fold.d.ts +27 -0
- package/dist-server/engine/kpi-fold.js +47 -4
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +128 -14
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-feed-registry.js +2 -16
- package/dist-server/engine/live-feed-registry.js.map +1 -1
- package/dist-server/engine/load-meter.d.ts +1 -1
- package/dist-server/engine/load-meter.js +12 -3
- package/dist-server/engine/load-meter.js.map +1 -1
- package/dist-server/engine/log.d.ts +18 -0
- package/dist-server/engine/log.js +80 -0
- package/dist-server/engine/log.js.map +1 -0
- package/dist-server/engine/loop-lag.d.ts +54 -0
- package/dist-server/engine/loop-lag.js +87 -0
- package/dist-server/engine/loop-lag.js.map +1 -0
- package/dist-server/engine/model-gap.d.ts +108 -0
- package/dist-server/engine/model-gap.js +95 -0
- package/dist-server/engine/model-gap.js.map +1 -0
- package/dist-server/engine/restart-policy.d.ts +3 -3
- package/dist-server/engine/restart-policy.js +4 -4
- package/dist-server/engine/restart-policy.js.map +1 -1
- package/dist-server/engine/runtime-key.js +1 -1
- package/dist-server/engine/runtime-key.js.map +1 -1
- package/dist-server/engine/stage-path.d.ts +83 -0
- package/dist-server/engine/stage-path.js +118 -0
- package/dist-server/engine/stage-path.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +352 -16
- package/dist-server/engine/twin-engine.js +1037 -166
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +34 -2
- package/dist-server/index.js.map +1 -1
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
- package/dist-server/migrations/index.js +7 -1
- package/dist-server/migrations/index.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -2
- package/dist-server/service/index.js +18 -7
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/discovery-result.d.ts +1 -1
- package/dist-server/service/reference/discovery-result.js +1 -1
- package/dist-server/service/reference/discovery-result.js.map +1 -1
- package/dist-server/service/reference/index.d.ts +4 -1
- package/dist-server/service/reference/index.js +6 -1
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +184 -3
- package/dist-server/service/reference/reference-adapter.js +28 -4
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-assessment.d.ts +68 -0
- package/dist-server/service/reference/reference-assessment.js +136 -0
- package/dist-server/service/reference/reference-assessment.js.map +1 -0
- package/dist-server/service/reference/reference-live.d.ts +12 -1
- package/dist-server/service/reference/reference-live.js +116 -11
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +47 -1
- package/dist-server/service/reference/reference-master.js +34 -4
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-probe.d.ts +92 -0
- package/dist-server/service/reference/reference-probe.js +186 -0
- package/dist-server/service/reference/reference-probe.js.map +1 -0
- package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
- package/dist-server/service/reference/reference-progress-subscription.js +94 -0
- package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
- package/dist-server/service/reference/reference-progress.d.ts +38 -0
- package/dist-server/service/reference/reference-progress.js +71 -0
- package/dist-server/service/reference/reference-progress.js.map +1 -0
- package/dist-server/service/reference/reference-resolver.js +210 -43
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/twin-reference.d.ts +32 -0
- package/dist-server/service/reference/twin-reference.js +10 -0
- package/dist-server/service/reference/twin-reference.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js +4 -3
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/journal-count.d.ts +21 -0
- package/dist-server/service/twin-event/journal-count.js +26 -0
- package/dist-server/service/twin-event/journal-count.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
- package/dist-server/service/twin-event/twin-event-keys.js +15 -18
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
- package/dist-server/service/twin-event/twin-event-type.js +18 -1
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +1 -0
- package/dist-server/service/twin-event/twin-event.js +9 -1
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
- package/dist-server/service/twin-ingest-window/index.js +10 -0
- package/dist-server/service/twin-ingest-window/index.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +72 -2
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
- package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
- package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
- package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
- package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
- package/dist-server/service/twin-model/epcis-coverage.js +19 -7
- package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
- package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
- package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
- package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
- package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
- package/dist-server/service/twin-model/isa95-coverage.js +23 -9
- package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
- package/dist-server/service/twin-model/item-ref.d.ts +24 -0
- package/dist-server/service/twin-model/item-ref.js +88 -0
- package/dist-server/service/twin-model/item-ref.js.map +1 -0
- package/dist-server/service/twin-model/name-index.d.ts +36 -0
- package/dist-server/service/twin-model/name-index.js +116 -0
- package/dist-server/service/twin-model/name-index.js.map +1 -0
- package/dist-server/service/twin-model/project-structure.js +1 -1
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/status-tally.d.ts +9 -0
- package/dist-server/service/twin-model/status-tally.js +37 -0
- package/dist-server/service/twin-model/status-tally.js.map +1 -0
- package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +156 -4
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-readiness/index.d.ts +2 -0
- package/dist-server/service/twin-readiness/index.js +6 -0
- package/dist-server/service/twin-readiness/index.js.map +1 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
- package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
- package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.d.ts +23 -3
- package/dist-shared/entity-delta.js +12 -8
- package/dist-shared/entity-delta.js.map +1 -1
- package/dist-shared/kpi-broadcast.js +1 -1
- package/dist-shared/kpi-broadcast.js.map +1 -1
- package/dist-shared/touched-items.d.ts +7 -0
- package/dist-shared/touched-items.js +80 -0
- package/dist-shared/touched-items.js.map +1 -0
- package/package.json +7 -7
- package/server/engine/canonical-ingest.ts +92 -7
- package/server/engine/command-routing.ts +1 -1
- package/server/engine/index.ts +4 -0
- package/server/engine/ingest-health.ts +704 -0
- package/server/engine/integration-coverage.ts +147 -0
- package/server/engine/integration-probes.ts +144 -0
- package/server/engine/integration-runner.ts +95 -0
- package/server/engine/integration-target-profile.ts +103 -0
- package/server/engine/kpi-fold.ts +72 -4
- package/server/engine/kpi-query.ts +129 -15
- package/server/engine/live-feed-registry.ts +2 -1
- package/server/engine/load-meter.ts +12 -3
- package/server/engine/log.ts +72 -0
- package/server/engine/loop-lag.ts +120 -0
- package/server/engine/model-gap.ts +168 -0
- package/server/engine/restart-policy.ts +4 -4
- package/server/engine/runtime-key.ts +1 -1
- package/server/engine/stage-path.ts +172 -0
- package/server/engine/twin-engine.ts +1135 -162
- package/server/index.ts +35 -2
- package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
- package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
- package/server/migrations/index.ts +7 -1
- package/server/service/index.ts +11 -0
- package/server/service/reference/discovery-result.ts +1 -1
- package/server/service/reference/index.ts +6 -1
- package/server/service/reference/reference-adapter.ts +197 -5
- package/server/service/reference/reference-assessment.ts +215 -0
- package/server/service/reference/reference-live.ts +123 -11
- package/server/service/reference/reference-master.ts +64 -4
- package/server/service/reference/reference-probe.ts +264 -0
- package/server/service/reference/reference-progress-subscription.ts +73 -0
- package/server/service/reference/reference-progress.ts +95 -0
- package/server/service/reference/reference-resolver.ts +200 -12
- package/server/service/reference/twin-reference.ts +39 -1
- package/server/service/twin-control/twin-control-mutation.ts +4 -3
- package/server/service/twin-event/journal-count.ts +53 -0
- package/server/service/twin-event/twin-event-keys.ts +16 -1
- package/server/service/twin-event/twin-event-type.ts +26 -2
- package/server/service/twin-event/twin-event.ts +7 -0
- package/server/service/twin-forecast/twin-forecast-query.ts +2 -1
- package/server/service/twin-ingest-window/index.ts +7 -0
- package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
- package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
- package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
- package/server/service/twin-instance/twin-instance.ts +1 -1
- package/server/service/twin-journal/twin-journal-query.ts +59 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +77 -3
- package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
- package/server/service/twin-model/axis-journal-evidence.ts +57 -0
- package/server/service/twin-model/epcis-coverage.ts +2 -8
- package/server/service/twin-model/iec61850-coverage.ts +2 -8
- package/server/service/twin-model/isa95-coverage.ts +22 -9
- package/server/service/twin-model/item-ref.ts +80 -0
- package/server/service/twin-model/name-index.ts +94 -0
- package/server/service/twin-model/project-structure.ts +1 -1
- package/server/service/twin-model/status-tally.ts +37 -0
- package/server/service/twin-model/twin-lineage-query.ts +38 -9
- package/server/service/twin-model/twin-model-item-query.ts +26 -27
- package/server/service/twin-model/twin-model-query.ts +155 -4
- package/server/service/twin-model/twin-model-tree-query.ts +7 -0
- package/server/service/twin-readiness/index.ts +3 -0
- package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
- package/server/service/twin-space/twin-space-resolver.ts +5 -4
- package/shared/entity-delta.ts +31 -7
- package/shared/kpi-broadcast.ts +1 -1
- package/shared/touched-items.ts +73 -0
- package/test/axis-journal-evidence.test.ts +71 -0
- package/test/axis-read.test.ts +101 -1
- package/test/boot-resume.test.ts +92 -26
- package/test/broadcast-cost-baseline.test.ts +200 -0
- package/test/broadcast-period.test.ts +58 -0
- package/test/canonical-ingest-vocabularies.test.ts +36 -1
- package/test/canonical-quantity-door.test.ts +61 -0
- package/test/command-routing.test.ts +1 -1
- package/test/declared-location-types.test.ts +89 -0
- package/test/discovery-result.test.ts +1 -1
- package/test/duration-estimators.test.ts +1 -1
- package/test/entity-delta.test.ts +2 -2
- package/test/ingest-bench.test.ts +3 -3
- package/test/ingest-health-engine.test.ts +248 -0
- package/test/ingest-health-wiring.test.ts +119 -0
- package/test/ingest-health.test.ts +306 -0
- package/test/ingest-history.test.ts +247 -0
- package/test/integration-probes.test.ts +103 -0
- package/test/integration-runner.test.ts +95 -0
- package/test/item-ref.test.ts +78 -0
- package/test/journal-read-discipline.test.ts +177 -0
- package/test/journal-retention.test.ts +133 -0
- package/test/journal-sort-axis.test.ts +142 -0
- package/test/journal-write-door.test.ts +110 -0
- package/test/journal-write-trend.test.ts +115 -0
- package/test/kernel-kind-guard.test.ts +4 -4
- package/test/kpi-fold.test.ts +90 -3
- package/test/kpi-query-bench.test.ts +2 -2
- package/test/lineage-survives-restart.test.ts +17 -1
- package/test/live-cursor-wiring.test.ts +87 -0
- package/test/live-feed-registry.test.ts +9 -1
- package/test/live-kernel-facts.test.ts +7 -7
- package/test/live-mirror-parity.test.ts +6 -0
- package/test/load-meter.test.ts +29 -15
- package/test/local-declarations.test.ts +1 -1
- package/test/log-stamp.test.ts +59 -0
- package/test/loop-lag.test.ts +82 -0
- package/test/model-gap.test.ts +153 -0
- package/test/oee-accumulator.test.ts +4 -0
- package/test/operational-vocabulary.test.ts +4 -4
- package/test/read-failure-visible.test.ts +145 -0
- package/test/reference-grounding.test.ts +70 -0
- package/test/resolve-ts-siblings.mjs +52 -0
- package/test/restart-policy.test.ts +7 -7
- package/test/revision-axis.test.ts +93 -0
- package/test/runtime-key.test.ts +2 -2
- package/test/scale-twin-bench.test.ts +2 -2
- package/test/spec-coverage.test.ts +1 -1
- package/test/stage-path.test.ts +95 -0
- package/test/standard-coverage.test.ts +15 -3
- package/test/status-tally.test.ts +55 -0
- package/test/structure-revision-db.test.ts +4 -0
- package/test/tenant-registry-db.test.ts +1 -1
- package/test/time-range.test.ts +152 -0
- package/test/touched-items.test.ts +61 -0
- package/test/twin-event-keys.test.ts +10 -2
- package/test/twin-model-item-db.test.ts +2 -0
- package/test/warm-start-seam.test.ts +4 -0
- package/test/yield-loop.test.ts +57 -2
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
2
|
+
import { Between } from 'typeorm'
|
|
3
|
+
|
|
4
|
+
import { ScalarObject, getRepository } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
import { TwinEngine } from '../../engine/index.js'
|
|
7
|
+
import { TwinIngestWindow } from './twin-ingest-window.js'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* 인제스트 창 **이력** — 재기동 뒤에도 추이를 이어 본다.
|
|
11
|
+
*
|
|
12
|
+
* ── 왜 따로 있나 (2026-08-20) ───────────────────────────────────────────────
|
|
13
|
+
* 창을 표로 남기기 시작했는데 **읽는 코드가 없었다.** 21행이 쌓여 있고 아무도 보지 않았다 — 이
|
|
14
|
+
* 프로젝트가 결함으로 보는 상태다(「필드만 만들고 아무도 읽지 않는다」). 표를 만든 이유가 「메모리
|
|
15
|
+
* 장부는 재기동에 사라지고 6시간까지인데 추이는 그보다 오래 봐야 한다」였으니, 목적이 미완이었다.
|
|
16
|
+
*
|
|
17
|
+
* ── 왜 `twinIngestHealth` 에 합치지 않나 ────────────────────────────────────
|
|
18
|
+
* 그 조회는 **화면이 자주 묻는다**(상세를 열 때마다, 목록은 응답에 요약이 함께 온다). 거기에 DB 주사를
|
|
19
|
+
* 얹으면 이 세션에서 없앤 것과 같은 종류의 비용이 돌아온다 — 자주 묻는 조회가 자라는 표를 읽는 것.
|
|
20
|
+
* 이력은 **다른 질문**이고 덜 자주 묻는다: 「더 뒤까지 보여 달라」.
|
|
21
|
+
*
|
|
22
|
+
* ── 이어 붙이기는 서버가 한다 ───────────────────────────────────────────────
|
|
23
|
+
* 표와 메모리는 겹친다(프로세스가 살아 있는 동안 닫힌 창은 둘 다에 있다). 겹침을 화면이 정리하게 두면
|
|
24
|
+
* 화면마다 규칙이 갈리고, 같은 구간이 두 번 그려지거나 사라진다. 그래서 여기서 한 벌로 이어 붙이고
|
|
25
|
+
* **어디서부터 메모리인지**(`liveFrom`)를 함께 낸다 — 화면이 근거를 말할 수 있게.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 한 번에 돌려주는 창 수 상한 — 144창 × 10분 = 24시간.
|
|
30
|
+
*
|
|
31
|
+
* 상한이 필요한 이유는 이 표가 **자란다**는 것이다(트윈 1,000개에 하루 14.4만 행). 상한에서 밀려난
|
|
32
|
+
* 수는 **세어서 낸다** — 조용히 자르면 화면이 「그 앞에는 아무 일도 없었다」로 읽는다.
|
|
33
|
+
*/
|
|
34
|
+
export const HISTORY_WINDOW_LIMIT = 144
|
|
35
|
+
|
|
36
|
+
/** 물을 수 있는 기간의 상한(시간). 넘겨도 여기서 자르고, 자른 사실을 낸다. */
|
|
37
|
+
export const HISTORY_HOURS_MAX = 168
|
|
38
|
+
|
|
39
|
+
@Resolver()
|
|
40
|
+
export class TwinIngestWindowQuery {
|
|
41
|
+
@Query(returns => ScalarObject, {
|
|
42
|
+
nullable: true,
|
|
43
|
+
description:
|
|
44
|
+
'Ingest windows for one twin over a past span, stitched from two sources so the trend survives a restart: closed windows persisted in the table (older) and the in-memory ledger (recent, roughly the last 6 hours). Overlap is resolved here rather than on screen — a window present in both is reported once, from memory, and `liveFrom` says where memory takes over so a screen can state its basis. Each window carries offered / accepted / rejected / undelivered plus the journal rows written and the write round-trips behind them; `acceptedRatio` is null when nothing was offered, because an empty window is not a perfect one. Deliberately separate from twinIngestHealth: that query is polled whenever a screen opens, and making it scan a growing table would reintroduce the cost this codebase just removed elsewhere. Counts that could not be returned are reported rather than dropped — `storedDropped` when the row cap was hit and `hoursClamped` when the requested span was longer than allowed. Null if the instance is not in the current domain.'
|
|
45
|
+
})
|
|
46
|
+
async twinIngestHistory(
|
|
47
|
+
@Arg('instanceId') instanceId: string,
|
|
48
|
+
@Arg('hours', type => Int, { nullable: true, description: 'How far back to read, in hours. Defaults to 24 and is clamped to 168.' })
|
|
49
|
+
hours: number | undefined,
|
|
50
|
+
@Ctx() context: ResolverContext
|
|
51
|
+
): Promise<any> {
|
|
52
|
+
const domainId = context.state.domain.id
|
|
53
|
+
/* 테넌트 격리 — 멈춘 트윈의 이력도 볼 수 있어야 한다(그때가 가장 보고 싶은 때다). */
|
|
54
|
+
if (!TwinEngine.owns(domainId, instanceId) && !(await TwinEngine.ownsRegistered(domainId, instanceId))) return null
|
|
55
|
+
|
|
56
|
+
const asked = Number.isFinite(Number(hours)) && Number(hours) > 0 ? Math.floor(Number(hours)) : 24
|
|
57
|
+
const span = Math.min(asked, HISTORY_HOURS_MAX)
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
const fromMs = now - span * 3_600_000
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* 표에서 그 구간의 닫힌 창을 읽는다. **상한을 둔다** — 자라는 표이고, 이 조회가 화면에서 열린다.
|
|
63
|
+
* 최신 쪽이 중요하므로 내려서 정렬해 가져오고, 낼 때 다시 올린다.
|
|
64
|
+
*/
|
|
65
|
+
const repo = getRepository(TwinIngestWindow)
|
|
66
|
+
const where = {
|
|
67
|
+
domain: { id: domainId } as any,
|
|
68
|
+
instanceId,
|
|
69
|
+
closedAt: Between(new Date(fromMs), new Date(now))
|
|
70
|
+
}
|
|
71
|
+
const rows = await repo.find({ where, order: { closedAt: 'DESC' }, take: HISTORY_WINDOW_LIMIT })
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
* 상한에 닿았으면 **몇 개가 밀렸는지 센다.** 「더 있음」만 말하면 화면이 그 앞을 짐작하게 된다.
|
|
75
|
+
* 상한에 닿지 않았으면 셀 필요가 없다(왕복을 아낀다).
|
|
76
|
+
*/
|
|
77
|
+
const storedDropped = rows.length === HISTORY_WINDOW_LIMIT ? Math.max(0, (await repo.count({ where })) - rows.length) : 0
|
|
78
|
+
|
|
79
|
+
const stored = rows.reverse().map(r => ({
|
|
80
|
+
from: r.openedAt?.toISOString?.() ?? new Date(r.openedAt as any).toISOString(),
|
|
81
|
+
to: r.closedAt?.toISOString?.() ?? new Date(r.closedAt as any).toISOString(),
|
|
82
|
+
offered: r.offered,
|
|
83
|
+
/* 통과는 **트윈에 닿은** 것만. 표의 값을 그대로 쓴다 — 읽는 쪽에서 다시 나누면 반올림이 갈린다. */
|
|
84
|
+
accepted: r.offered - r.rejected - (r.undelivered ?? 0),
|
|
85
|
+
rejected: r.rejected,
|
|
86
|
+
undelivered: r.undelivered ?? 0,
|
|
87
|
+
acceptedRatio: typeof r.acceptedRatio === 'number' ? r.acceptedRatio : null,
|
|
88
|
+
rows: r.journaledRows ?? 0,
|
|
89
|
+
flushes: r.journalFlushes ?? 0,
|
|
90
|
+
source: 'stored' as const
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
/* 메모리 장부의 닫힌 창 — 이쪽이 최신이고, 겹치는 구간은 이쪽을 쓴다(같은 사실의 원본이다). */
|
|
94
|
+
const live = (TwinEngine.ingestHealthOf(domainId, instanceId).trend ?? []).map((w: any) => ({
|
|
95
|
+
...w,
|
|
96
|
+
source: 'live' as const
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* 겹침 정리 — 창의 **여는 시각**이 키다. 같은 창이 표와 메모리에 둘 다 있으면 하나만 낸다.
|
|
101
|
+
* 시각 문자열을 그대로 키로 쓰지 않는다: 드라이버가 밀리초를 자르거나 형식을 바꿀 수 있어
|
|
102
|
+
* 같은 창이 다른 키가 된다. epoch 로 견준다.
|
|
103
|
+
*/
|
|
104
|
+
const liveStarts = new Set(live.map((w: any) => Date.parse(w.from)))
|
|
105
|
+
const windows = [...stored.filter(w => !liveStarts.has(Date.parse(w.from))), ...live]
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
windows,
|
|
109
|
+
hours: span,
|
|
110
|
+
/* 물은 것보다 짧게 잘렸으면 밝힌다. */
|
|
111
|
+
...(span < asked ? { hoursClamped: asked } : {}),
|
|
112
|
+
from: new Date(fromMs).toISOString(),
|
|
113
|
+
to: new Date(now).toISOString(),
|
|
114
|
+
/** 표에서 읽은 창 수 · 상한에서 밀려난 수. */
|
|
115
|
+
storedCount: stored.length,
|
|
116
|
+
storedDropped,
|
|
117
|
+
/**
|
|
118
|
+
* 어디서부터 메모리인가 — 화면이 근거를 말할 수 있게. 메모리에 닫힌 창이 없으면 `null` 이다
|
|
119
|
+
* (재기동 직후가 그렇다: 그때 보이는 것은 전부 표에서 온 것이다).
|
|
120
|
+
*/
|
|
121
|
+
liveFrom: live.length ? live[0].from : null,
|
|
122
|
+
liveCount: live.length
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { twinWarn } from '../../engine/log.js'
|
|
2
|
+
import { getRepository } from '@things-factory/shell'
|
|
3
|
+
|
|
4
|
+
import { TwinEngine } from '../../engine/index.js'
|
|
5
|
+
import { TwinIngestWindow } from './twin-ingest-window.js'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* 닫힌 창을 표로 옮긴다 — **영속이 붙는 유일한 자리.**
|
|
9
|
+
*
|
|
10
|
+
* 커널 층(`engine/ingest-health.ts`)은 저장 계층을 모른다. 창이 닫힐 때 부를 곳을 엔진이 문으로 열어
|
|
11
|
+
* 두었고(`onIngestWindowClosed`), 이 파일이 부팅에서 한 번 등록한다. 그래서 기록 경로가 갈리지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* ── 실패를 삼키지 않는다 ────────────────────────────────────────────────────
|
|
14
|
+
* 쓰기가 터져도 메모리 장부는 계속 굴러간다(`rollIngestWindow` 가 훅을 감싼다). 다만 **조용히 넘기지는
|
|
15
|
+
* 않는다** — 실패를 로그로 남긴다. 그러지 않으면 추이 표에 구멍이 생긴 이유를 나중에 알 수 없다.
|
|
16
|
+
*
|
|
17
|
+
* ── 왜 await 하지 않나 ──────────────────────────────────────────────────────
|
|
18
|
+
* 이 훅은 인제스트 경로에서 불린다(라이브 피드의 콜백 안). 여기서 DB 를 기다리면 **유입이 DB 속도에
|
|
19
|
+
* 묶인다** — 이 기능이 막으려던 부하가 그 모양으로 돌아온다. 그래서 띄워 보내고 결과만 로그로 받는다.
|
|
20
|
+
*/
|
|
21
|
+
export function installIngestWindowWriter(): void {
|
|
22
|
+
TwinEngine.onIngestWindowClosed((at, closed) => {
|
|
23
|
+
const repo = getRepository(TwinIngestWindow)
|
|
24
|
+
const offered = closed.offered
|
|
25
|
+
repo
|
|
26
|
+
.save(
|
|
27
|
+
repo.create({
|
|
28
|
+
domain: { id: at.domainId } as any,
|
|
29
|
+
instanceId: at.instanceId,
|
|
30
|
+
openedAt: new Date(closed.startMs),
|
|
31
|
+
closedAt: new Date(closed.endMs),
|
|
32
|
+
offered,
|
|
33
|
+
rejected: closed.rejected,
|
|
34
|
+
undelivered: closed.undelivered,
|
|
35
|
+
/* 적힌 것도 같은 행에 — 창이 유입 없이 닫혔어도(시뮬) 이 두 수는 사실이다. */
|
|
36
|
+
journaledRows: closed.rows,
|
|
37
|
+
journalFlushes: closed.flushes,
|
|
38
|
+
/*
|
|
39
|
+
* 제시가 0 이면 비워 둔다 — 1 로 채우면 「전부 통과」로 읽힌다.
|
|
40
|
+
* 통과는 **트윈에 닿은** 것만이다: 버려진 것을 빼지 않으면 멈춘 트윈의 창이 100% 로 남는다.
|
|
41
|
+
*/
|
|
42
|
+
...(offered > 0
|
|
43
|
+
? { acceptedRatio: round4((offered - closed.rejected - closed.undelivered) / offered) }
|
|
44
|
+
: {})
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
.catch(err =>
|
|
48
|
+
twinWarn(
|
|
49
|
+
`[twin-ingest-window] "${at.instanceId}" window not persisted (trend will have a gap here):`,
|
|
50
|
+
err?.message ?? err
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 0.999 가 1 로 반올림되면 「전부 통과」로 읽힌다 — 자릿수를 넉넉히 준다(메모리 장부와 같은 규칙). */
|
|
57
|
+
const round4 = (v: number) => Math.round(v * 10000) / 10000
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
|
|
2
|
+
import { ObjectType, Field, ID, Int, Float } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { Domain } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* 인제스트 창 하나 — **동기화 추이를 재기동 뒤에도 이어 보기 위한 행.**
|
|
8
|
+
*
|
|
9
|
+
* ── 왜 창마다이고 건마다가 아닌가 ───────────────────────────────────────────
|
|
10
|
+
* 거부마다 행을 쓰면 라이브 계측 규모에서 초당 1만 행이고, 그 부하가 이벤트 루프로 돌아온다. 창마다면
|
|
11
|
+
* 트윈 1,000개에 하루 14.4만 행(약 14MB)이다. 그래서 **닫힌 창 하나가 영속의 단위**다.
|
|
12
|
+
*
|
|
13
|
+
* 건마다 남기는 자리는 이미 따로 있다: 시나리오 경로가 `PayloadLog` 로 dead-letter 를 남긴다(배치성
|
|
14
|
+
* 유입이라 건수가 감당된다). 이 표는 그것을 대신하지 않고 **추이**를 답한다.
|
|
15
|
+
*
|
|
16
|
+
* ── 왜 사유·표본은 여기 없나 ────────────────────────────────────────────────
|
|
17
|
+
* 사유 문구는 길고 표본에는 **고객의 레코드 원문**이 들어 있다. 그것을 창마다 영속하면 표가 커지고
|
|
18
|
+
* 고객 데이터가 오래 남는다. 사유와 표본은 메모리 장부가 답하고(최근 6시간), 이 표는 숫자만 든다.
|
|
19
|
+
*
|
|
20
|
+
* ── 고치지 않는다 ───────────────────────────────────────────────────────────
|
|
21
|
+
* 닫힌 창은 지나간 사실이다. 그래서 `updatedAt`·`deletedAt` 을 두지 않는다(저널과 같은 규율).
|
|
22
|
+
*/
|
|
23
|
+
@Entity()
|
|
24
|
+
@Index('ix_twin_ingest_window_0', (e: TwinIngestWindow) => [e.domain, e.instanceId, e.closedAt], { unique: false })
|
|
25
|
+
@ObjectType({
|
|
26
|
+
description:
|
|
27
|
+
'One closed ingest window for a twin: how many records were offered, accepted and rejected between two times. Written when a window closes (about every 10 minutes per twin) so the sync trend survives a restart and reaches further back than the in-memory ledger. Reasons and rejected-record samples are deliberately not stored here — they live in the in-memory ledger, which covers roughly the last 6 hours.'
|
|
28
|
+
})
|
|
29
|
+
export class TwinIngestWindow {
|
|
30
|
+
@PrimaryGeneratedColumn('uuid')
|
|
31
|
+
@Field(type => ID, { description: 'Unique identifier of the window record.' })
|
|
32
|
+
readonly id: string
|
|
33
|
+
|
|
34
|
+
@ManyToOne(type => Domain)
|
|
35
|
+
@Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })
|
|
36
|
+
domain?: Domain
|
|
37
|
+
|
|
38
|
+
@RelationId((e: TwinIngestWindow) => e.domain)
|
|
39
|
+
domainId?: string
|
|
40
|
+
|
|
41
|
+
@Column()
|
|
42
|
+
@Field({ description: 'Twin runtime instance this window belongs to.' })
|
|
43
|
+
instanceId: string
|
|
44
|
+
|
|
45
|
+
@Column()
|
|
46
|
+
@Field({ description: 'When the window opened.' })
|
|
47
|
+
openedAt: Date
|
|
48
|
+
|
|
49
|
+
@Column()
|
|
50
|
+
@Field({ description: 'When the window closed. Windows close on the next ingest after the window length elapses, so a feed that stops leaves its last window open and unwritten.' })
|
|
51
|
+
closedAt: Date
|
|
52
|
+
|
|
53
|
+
@Column({ type: 'int' })
|
|
54
|
+
@Field(type => Int, { description: 'Records offered in this window, whether or not they were accepted.' })
|
|
55
|
+
offered: number
|
|
56
|
+
|
|
57
|
+
@Column({ type: 'int' })
|
|
58
|
+
@Field(type => Int, { description: 'Records the kernel refused in this window because they did not map — the fix is in the mapping.' })
|
|
59
|
+
rejected: number
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* 거부와 따로 센다 — **처방이 다르다.**
|
|
63
|
+
*
|
|
64
|
+
* 트윈이 라이브로 돌지 않으면 커널이 레코드를 버린다. 그것을 거부에 합치면 「매핑을 고쳐라」로 읽히고,
|
|
65
|
+
* 통과에 합치면 멈춘 트윈이 100% 로 보인다(실제로 그렇게 떴다). 그래서 제3의 수로 남긴다.
|
|
66
|
+
*/
|
|
67
|
+
@Column({ type: 'int', default: 0 })
|
|
68
|
+
@Field(type => Int, {
|
|
69
|
+
description:
|
|
70
|
+
'Records that mapped fine but never reached the twin, because the twin was not running live while the feed kept delivering. Distinct from rejected: the fix here is to start the twin or disconnect the feed, not to change the mapping. Offered minus rejected minus undelivered is what actually reached the twin.'
|
|
71
|
+
})
|
|
72
|
+
undelivered: number
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* 통과율을 **함께 적는다.** 읽는 쪽에서 나누게 하면 소비처마다 반올림이 갈리고(0.999 를 1 로 쓰는
|
|
76
|
+
* 곳이 생긴다), 제시가 0 인 창에서 0으로 나누는 실수가 각자 생긴다. 제시가 0 이면 **비어 둔다** —
|
|
77
|
+
* 1 로 채우면 「전부 통과」로 읽힌다.
|
|
78
|
+
*/
|
|
79
|
+
@Column({ type: 'float', nullable: true })
|
|
80
|
+
@Field(type => Float, {
|
|
81
|
+
nullable: true,
|
|
82
|
+
description:
|
|
83
|
+
'Share of offered records that reached the twin (0–1), rounded to four places. Null when nothing was offered in this window — an empty window is not a perfect one, and writing 1 there would read as "everything passed".'
|
|
84
|
+
})
|
|
85
|
+
acceptedRatio?: number
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
* ── 적힌 것도 같은 창에 든다 ────────────────────────────────────────────────
|
|
89
|
+
* 유입과 **다른 축**이다. 시뮬 트윈은 받는 것 없이 자기가 낸 사실을 적으므로 `offered` 가 0 이고
|
|
90
|
+
* `journaledRows` 는 0 이 아니다. 이 칸이 없으면 도는 트윈 대부분이 시뮬인 서버의 활동이 어느 추이에도
|
|
91
|
+
* 남지 않는다(계기는 지금만 답하고 재기동에 0 으로 돌아간다).
|
|
92
|
+
*
|
|
93
|
+
* 두 수를 따로 세는 이유: 2026-08-20 에 고친 것이 **행당 왕복**이다(델타마다 한 번 → 창당 한 번).
|
|
94
|
+
* 행만 세면 그 개선도, 되돌아가는 것도 보이지 않는다.
|
|
95
|
+
*/
|
|
96
|
+
@Column({ type: 'int', default: 0 })
|
|
97
|
+
@Field(type => Int, {
|
|
98
|
+
description:
|
|
99
|
+
'Journal rows written for this twin during this window. This is a different axis from ingest: a twin driven by a simulator source receives nothing (offered = 0) yet writes what it produces, so without this column its activity appears in no trend at all.'
|
|
100
|
+
})
|
|
101
|
+
journaledRows: number
|
|
102
|
+
|
|
103
|
+
@Column({ type: 'int', default: 0 })
|
|
104
|
+
@Field(type => Int, {
|
|
105
|
+
description:
|
|
106
|
+
'Number of journal write round-trips (flushes) behind those rows. Counted separately from the rows themselves so that a regression back to per-delta writes — the defect fixed on 2026-08-20, where each kernel delta cost one insert — is visible as rows-per-flush falling back towards 1.'
|
|
107
|
+
})
|
|
108
|
+
journalFlushes: number
|
|
109
|
+
|
|
110
|
+
@CreateDateColumn()
|
|
111
|
+
@Field({ nullable: true, description: 'When this record was written.' })
|
|
112
|
+
createdAt?: Date
|
|
113
|
+
}
|
|
@@ -74,7 +74,7 @@ export class TwinInstance {
|
|
|
74
74
|
* 트윈이 하나는 이어가고 하나는 매번 처음부터 재현할 수 있다. 그래서 축은 남고 이름만 바뀌었다.
|
|
75
75
|
*
|
|
76
76
|
* **비어 있을 수 없고, 기본값도 없다.** 예전에는 미선언이 조용히 `sim-experiment`(=저널 초기화)로
|
|
77
|
-
* 떨어졌다 — 값이 하나 어긋나는 순간 이력을 지우는 길이다. 이제 읽는 쪽이
|
|
77
|
+
* 떨어졌다 — 값이 하나 어긋나는 순간 이력을 지우는 길이다. 이제 읽는 쪽이 **오류를 낸다**
|
|
78
78
|
* (`engine/restart-policy.ts`). 선언은 프로비저닝하는 쪽의 몫이다.
|
|
79
79
|
*/
|
|
80
80
|
@Column()
|
|
@@ -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
|
-
/*
|
|
56
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
|
@@ -61,6 +64,45 @@ export class TwinLifecycleMutation {
|
|
|
61
64
|
return true
|
|
62
65
|
}
|
|
63
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
|
+
|
|
64
106
|
/** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */
|
|
65
107
|
@Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
|
|
66
108
|
@Mutation(returns => Boolean, { description: 'Delete a twin instance: stop it, remove the registry entry and purge its event journal.' })
|
|
@@ -82,9 +124,41 @@ export class TwinLifecycleMutation {
|
|
|
82
124
|
return TwinEngine.instanceIdsOfSpace(context.state.domain.id, spaceId)
|
|
83
125
|
}
|
|
84
126
|
|
|
85
|
-
@Query(returns => [ScalarObject], {
|
|
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
|
+
})
|
|
86
131
|
async twinInstanceList(@Ctx() context: ResolverContext): Promise<any[]> {
|
|
87
|
-
|
|
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
|
+
})
|
|
88
162
|
}
|
|
89
163
|
|
|
90
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)
|
|
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], {
|
|
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
|
*
|