@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
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { axisSeenInJournal } from './axis-journal-evidence.js'
|
|
2
|
+
import { statusTally } from './status-tally.js'
|
|
3
|
+
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
1
4
|
import { Arg, Ctx, Query, Resolver } from 'type-graphql'
|
|
2
5
|
|
|
3
6
|
import { ScalarObject, getRepository } from '@things-factory/shell'
|
|
4
7
|
|
|
5
8
|
import { TwinEngine } from '../../engine/index.js'
|
|
9
|
+
import { modelGapOf } from '../../engine/model-gap.js'
|
|
6
10
|
import { readBoardEquipment } from '@operato/twin-kernel'
|
|
7
11
|
import { TwinInstance } from '../twin-instance/twin-instance.js'
|
|
8
12
|
import { TwinSpace } from '../twin-space/twin-space.js'
|
|
@@ -98,6 +102,27 @@ function valuesAt(obj: any, field: string): any[] {
|
|
|
98
102
|
|
|
99
103
|
const idOf = (o: any): string | null => (typeof o === 'string' ? o : o?.id ?? o?.key ?? o?.gtin ?? null)
|
|
100
104
|
|
|
105
|
+
/** 상태가 빈 축의 이유 — 저널에 그 사실이 있으면 그렇게 말한다. 판단할 수 없으면 예전 그대로. */
|
|
106
|
+
async function emptyStateReason(domainId: string, instanceId: string, axis: string): Promise<string> {
|
|
107
|
+
try {
|
|
108
|
+
const seen = await axisSeenInJournal(getRepository(TwinEvent), { domain: { id: domainId }, instanceId }, axis)
|
|
109
|
+
/* 판단할 수 없는 축(`null`)은 예전 답을 그대로 쓴다 — 모르는 것을 새 사실로 만들지 않는다. */
|
|
110
|
+
return seen ? 'observed-not-in-state' : 'none-yet'
|
|
111
|
+
} catch {
|
|
112
|
+
/* 저널을 못 읽었다고 카드가 없는 말을 하지는 않는다. */
|
|
113
|
+
return 'none-yet'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 되세운 상태를 담아 둔다 — **열쇠에 저널 끝 리비전이 들어 있어** 새 사건이 오면 저절로 낡는다.
|
|
119
|
+
*
|
|
120
|
+
* 접기는 저널 전체를 훑는 일이라(리비전 12만에서 2~3초) 모델 화면을 열 때마다 그 값을 낼 수 없다.
|
|
121
|
+
* 프로세스 안에만 두고 몇 개만 남긴다 — 다시 뜨면 다시 접는다(정확함이 캐시보다 앞선다).
|
|
122
|
+
*/
|
|
123
|
+
const RECALL_CACHE = new Map<string, { snap: any; heardAt: string | null }>()
|
|
124
|
+
const RECALL_CACHE_MAX = 8
|
|
125
|
+
|
|
101
126
|
@Resolver()
|
|
102
127
|
export class TwinModelQuery {
|
|
103
128
|
/**
|
|
@@ -188,6 +213,60 @@ export class TwinModelQuery {
|
|
|
188
213
|
}
|
|
189
214
|
return { snap, folded, observing }
|
|
190
215
|
})())
|
|
216
|
+
|
|
217
|
+
/*
|
|
218
|
+
* ── **라이브가 잊은 것을 저널이 안다** (2026-08-23) ─────────────────────────
|
|
219
|
+
*
|
|
220
|
+
* 미러의 관측은 메모리에만 산다. 재기동하면 빈 채로 시작하고, 원본이 다시 말해 줄 때 채워진다 —
|
|
221
|
+
* 그런데 읽기 커서가 정직해진 뒤로는 원본이 **지난 것을 다시 말하지 않는다.** 그래서 재고 741건이
|
|
222
|
+
* 저널에 있는데 화면은 「0」을 보였다. 사용자가 「재고현황이 없다」고 물은 자리가 이것이다.
|
|
223
|
+
*
|
|
224
|
+
* 그 0 은 사실이 아니라 **우리가 잊은 것**이다. 저널을 접으면 그대로 되살아난다(실측: 리비전
|
|
225
|
+
* 97,689 을 2.4초에 접어 물품 741 · 작업 9,145 · 지시 2,856).
|
|
226
|
+
*
|
|
227
|
+
* ── 그래서 접는다. 다만 **언제 들은 값인지 함께 낸다** ──────────────────────
|
|
228
|
+
* 저널을 접는 것은 **원천이 우리에게 말한 것을 다시 세우는 일**이지, 없는 값을 심는 일이 아니다.
|
|
229
|
+
* 그러나 물품·재고처럼 **절대값인 축**은 우리가 못 듣는 사이 떠났을 수 있다 — 그래서 값과 함께
|
|
230
|
+
* `heardAt`(그 접기의 시계)을 낸다. 사용자가 신선도를 스스로 판정할 수 있어야 하고, 화면은 그것을
|
|
231
|
+
* 값 옆에 적는다. 시각 없이 수만 내면 낡은 값을 지금으로 읽는다.
|
|
232
|
+
*
|
|
233
|
+
* 접는 조건을 좁게 둔다: **관측 축이 비어 있고, 저널에 그 축의 사실이 있을 때만**(그 판정은 색인
|
|
234
|
+
* 한 줄이다). 그리고 한 요청에 **한 번만** 접는다 — 위 `stateOnce` 와 같은 규율이다.
|
|
235
|
+
*/
|
|
236
|
+
let journalPromise: Promise<{ snap: any; heardAt: string | null } | null> | null = null
|
|
237
|
+
const journalOnce = () =>
|
|
238
|
+
(journalPromise ??= (async () => {
|
|
239
|
+
try {
|
|
240
|
+
const tip = await getRepository(TwinEvent).findOne({
|
|
241
|
+
where: { domain: { id: domainId } as any, instanceId } as any,
|
|
242
|
+
order: { revision: 'DESC' } as any
|
|
243
|
+
})
|
|
244
|
+
if (!tip?.revision) return null
|
|
245
|
+
/*
|
|
246
|
+
* **한 번 접은 것을 다시 접지 않는다.** 접기는 저널 전체를 훑는 일이다(리비전 12만에서 2~3초).
|
|
247
|
+
* 이 화면은 사람이 여러 번 들어오는 자리이므로 그대로 두면 매번 그 값을 낸다 — 그리고 저널이
|
|
248
|
+
* 수백만 행이 되면 그 비용이 화면을 못 쓰게 만든다.
|
|
249
|
+
*
|
|
250
|
+
* 열쇠에 **저널 끝의 리비전**을 넣는다: 새 사건이 오면 열쇠가 달라지므로 캐시가 저절로 낡는다
|
|
251
|
+
* (캐시가 사실을 이기지 않는다). 프로세스 안에만 두고 몇 개만 남긴다.
|
|
252
|
+
*/
|
|
253
|
+
const key = `${domainId}:${instanceId}:${tip.revision}`
|
|
254
|
+
const hit = RECALL_CACHE.get(key)
|
|
255
|
+
if (hit) return hit
|
|
256
|
+
const snap = unwrapState(await TwinEngine.recover(domainId, instanceId, tip.revision))
|
|
257
|
+
if (!snap) return null
|
|
258
|
+
/* 그 접기의 시계가 「언제 들은 값인가」다. 없으면 저널 끝의 사건 시각으로 답한다. */
|
|
259
|
+
const out = { snap, heardAt: (snap as any).nowTime ?? tip.eventTime ?? null }
|
|
260
|
+
RECALL_CACHE.set(key, out)
|
|
261
|
+
/* 오래된 열쇠부터 버린다 — 트윈 몇 개를 번갈아 봐도 메모리가 자라지 않게. */
|
|
262
|
+
while (RECALL_CACHE.size > RECALL_CACHE_MAX) RECALL_CACHE.delete(RECALL_CACHE.keys().next().value as string)
|
|
263
|
+
return out
|
|
264
|
+
} catch {
|
|
265
|
+
/* 접지 못했으면 아무 말도 만들지 않는다 — 라이브가 말한 것을 그대로 둔다. */
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
})())
|
|
269
|
+
|
|
191
270
|
const concepts = await Promise.all(
|
|
192
271
|
TWIN_AXES.map(async (a: any) => {
|
|
193
272
|
/*
|
|
@@ -210,6 +289,17 @@ export class TwinModelQuery {
|
|
|
210
289
|
count: null,
|
|
211
290
|
notApplicable: true,
|
|
212
291
|
emptyBecause: 'not-applicable',
|
|
292
|
+
/*
|
|
293
|
+
* **그 개념의 성질** — 선언인가 관측인가 (2026-08-22).
|
|
294
|
+
*
|
|
295
|
+
* `basis` 와 다르다: `basis` 는 「이 값이 어디서 왔나」이고 트윈마다·시점마다 달라진다(같은
|
|
296
|
+
* 축이 한 트윈에서 `journal`, 다른 트윈에서 `state` 로 온다). 그것으로 묶으면 같은 축이 화면
|
|
297
|
+
* 마다 다른 묶음에 들어간다. `source` 는 트윈과 무관하게 고정이므로 묶는 축이 된다.
|
|
298
|
+
*
|
|
299
|
+
* 화면이 축 이름을 열거하지 않아도 되고, 축이 늘면 자동으로 맞는 묶음에 들어간다 —
|
|
300
|
+
* 카탈로그가 정본이다.
|
|
301
|
+
*/
|
|
302
|
+
source: axisSource(a.axis),
|
|
213
303
|
basis: 'declaration'
|
|
214
304
|
}
|
|
215
305
|
}
|
|
@@ -243,6 +333,15 @@ export class TwinModelQuery {
|
|
|
243
333
|
* 0 으로 내면 "작업이 없는 공장" 으로 읽히고, 그건 거짓이다(관측이 없는 것과 0 건은 다르다).
|
|
244
334
|
* 이 화면이 관측 열에서 내내 지켜 온 구분을 카드에서도 지킨다.
|
|
245
335
|
*/
|
|
336
|
+
/*
|
|
337
|
+
* **「아직 없다」와 「왔는데 상태에 없다」는 다른 사실이다** (2026-08-22).
|
|
338
|
+
*
|
|
339
|
+
* 화면이 「작업 — 아직 하나도 없습니다」라고 말했는데 저널에는 `task.status` 5,786건이 있었고
|
|
340
|
+
* 마지막 상태가 `in-progress` 였다(원인은 커널의 내보내기였고 그 레인에 넘겼다). 카드가 단정한
|
|
341
|
+
* 탓에 사용자가 그 차이를 직접 조사해야 했다 — 화면이 아는 것을 말하지 않은 자리다.
|
|
342
|
+
*
|
|
343
|
+
* 그래서 상태가 빈 축은 저널을 **한 건만** 물어본다(색인 한 줄, `journal-count` 의 문).
|
|
344
|
+
*/
|
|
246
345
|
if (axisSource(a.axis) === 'state') {
|
|
247
346
|
/* 스냅샷은 `{revision, state}` 봉투다 — 벗기지 않으면 축이 한 겹 안에 있어 언제나 `null` 이
|
|
248
347
|
된다(커널은 도는데 카드가 "모름" 으로 보였다). 벗기는 규칙의 집은 `warm-start` 하나다. */
|
|
@@ -255,7 +354,25 @@ export class TwinModelQuery {
|
|
|
255
354
|
const { snap, folded, observing } = await stateOnce()
|
|
256
355
|
/* 축이 사는 자리는 **커널 선언이 말한다**(`path`) — 축 이름으로만 읽으면 중첩된 축이
|
|
257
356
|
언제나 「관측 없음」이 된다(에너지의 수요 구간이 그렇다). 규칙은 `axisValueOf` 한 곳. */
|
|
258
|
-
|
|
357
|
+
let bucket = snap ? axisValueOf(snap, a) ?? null : null
|
|
358
|
+
let heardAt: string | null = null
|
|
359
|
+
let fromJournal = false
|
|
360
|
+
/*
|
|
361
|
+
* 라이브가 잊은 축은 **저널에서 되세운다**(§`journalOnce`). 그 축의 사실이 저널에 있을 때만
|
|
362
|
+
* 접고, 값과 함께 「언제 들은 값인가」를 낸다 — 시각 없이 수만 내면 낡은 값을 지금으로 읽는다.
|
|
363
|
+
*/
|
|
364
|
+
if (!folded && Array.isArray(bucket) && bucket.length === 0) {
|
|
365
|
+
const seen = await axisSeenInJournal(getRepository(TwinEvent), { domain: { id: domainId }, instanceId }, a.axis).catch(() => null)
|
|
366
|
+
if (seen) {
|
|
367
|
+
const j = await journalOnce()
|
|
368
|
+
const jb = j?.snap ? axisValueOf(j.snap, a) ?? null : null
|
|
369
|
+
if (Array.isArray(jb) && jb.length) {
|
|
370
|
+
bucket = jb
|
|
371
|
+
heardAt = j?.heardAt ?? null
|
|
372
|
+
fromJournal = true
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
259
376
|
return {
|
|
260
377
|
axis: a.axis,
|
|
261
378
|
label: a.label,
|
|
@@ -264,13 +381,34 @@ export class TwinModelQuery {
|
|
|
264
381
|
standardClass: a.standardClass,
|
|
265
382
|
typeRole: a.typeRole ?? null,
|
|
266
383
|
count: Array.isArray(bucket) ? bucket.length : null,
|
|
384
|
+
/*
|
|
385
|
+
* **수 하나가 뜻을 바꾸는 자리** (2026-08-23).
|
|
386
|
+
*
|
|
387
|
+
* 미러가 완료된 작업까지 상태에 들고 있게 되면서 「작업 2,820」이 되었다. 이 카드는 상태의
|
|
388
|
+
* 인구조사이므로 그 수가 맞다 — 그런데 사람은 그것을 「지금 할 일 2,820건」으로 읽는다.
|
|
389
|
+
* 수를 줄여 거짓으로 만드는 대신 **무엇으로 이루어진 수인지** 함께 낸다.
|
|
390
|
+
*
|
|
391
|
+
* 낱말은 커널의 것을 그대로 쓴다(`created`·`assigned`·`in-progress`·`completed`). 화면이
|
|
392
|
+
* 라벨을 입히고, 없으면 낱말이 그대로 보여 누락이 드러난다.
|
|
393
|
+
*/
|
|
394
|
+
...statusTally(bucket),
|
|
267
395
|
/*
|
|
268
396
|
* **비었으면 왜 비었는지 말한다.** 0 과 `null` 만 내면 화면은 "없다" 까지만 말하고, 사용자는
|
|
269
397
|
* "안 적어 놨나 · 아직 안 일어났나 · 못 세었나" 를 구별할 수 없다. 이 화면이 관측 열에서
|
|
270
398
|
* 내내 지켜 온 구분(§observedAbsence)을 카드에서도 지킨다.
|
|
271
399
|
*/
|
|
272
|
-
...(Array.isArray(bucket)
|
|
273
|
-
|
|
400
|
+
...(Array.isArray(bucket)
|
|
401
|
+
? bucket.length
|
|
402
|
+
? {}
|
|
403
|
+
: { emptyBecause: await emptyStateReason(domainId, instanceId, a.axis) }
|
|
404
|
+
: { emptyBecause: 'not-observed' }),
|
|
405
|
+
source: axisSource(a.axis),
|
|
406
|
+
basis: folded || fromJournal ? 'journal' : 'state',
|
|
407
|
+
/*
|
|
408
|
+
* **언제 들은 값인가** — 저널에서 되세운 축만 갖는다. 라이브가 답한 축은 지금이므로 없다.
|
|
409
|
+
* 이 값이 없으면 화면이 낡은 수를 지금으로 읽는다(그 구별이 이 카드의 전부다).
|
|
410
|
+
*/
|
|
411
|
+
...(fromJournal && heardAt ? { heardAt, recalled: true } : {}),
|
|
274
412
|
observing,
|
|
275
413
|
/* 선언이 아니라 관측인 축 — 화면이 "원본이 침묵했다" 로 읽지 않게 사실로 말한다. */
|
|
276
414
|
observedOnly: true,
|
|
@@ -301,6 +439,7 @@ export class TwinModelQuery {
|
|
|
301
439
|
entity && inDocument > 0 ? 'not-projected' : raw == null ? 'not-declared' : 'declared-empty'
|
|
302
440
|
}),
|
|
303
441
|
/* 'rows' = DB 가 셌다(질의 가능) · 'document' = 문서를 세었다(아직 이주 전). */
|
|
442
|
+
source: axisSource(a.axis),
|
|
304
443
|
basis: entity ? 'rows' : 'document',
|
|
305
444
|
...(entity ? { documentCount: inDocument, stale } : {}),
|
|
306
445
|
/* 표준 투영이 없는 축은 **숨기지 않고** 그 사실을 낸다(ADR-0031). */
|
|
@@ -321,12 +460,24 @@ export class TwinModelQuery {
|
|
|
321
460
|
* 트윈이 안 실행 중이면 비교할 상대가 없다 — 그때는 `null` 이다(0 이 아니다. 없는 것과
|
|
322
461
|
* 세어 보지 못한 것은 다르다).
|
|
323
462
|
*/
|
|
463
|
+
/*
|
|
464
|
+
* 도출은 **한 벌**이다(`engine/model-gap.ts`). 예전에는 이 자리에 인라인으로 있었는데, 같은 사실을
|
|
465
|
+
* 「현장과 맞춰지고 있나」 화면도 물어야 해서 두 곳이 다른 수를 말할 위험이 생겼다.
|
|
466
|
+
*
|
|
467
|
+
* 이 화면은 **전체 목록**이 필요하다(인스펙션이니까). 그래서 상한 없는 원본을 여기서 만들고,
|
|
468
|
+
* 상한이 걸린 요약은 `modelGapOf` 가 답한다 — 같은 판정(`origin === 'observed'`)을 쓴다.
|
|
469
|
+
*/
|
|
324
470
|
const snap = TwinEngine.snapshot(domainId, instanceId)
|
|
471
|
+
const gap = modelGapOf(snap)
|
|
325
472
|
const observedOnly = snap
|
|
326
473
|
? {
|
|
327
474
|
equipment: (snap.equipment ?? []).filter((e: any) => e?.origin === 'observed').map((e: any) => e.id),
|
|
328
475
|
locations: (snap.locations ?? []).filter((l: any) => l?.origin === 'observed').map((l: any) => l.id),
|
|
329
|
-
persons: (snap.persons ?? []).filter((p: any) => p?.origin === 'observed').map((p: any) => p.id)
|
|
476
|
+
persons: (snap.persons ?? []).filter((p: any) => p?.origin === 'observed').map((p: any) => p.id),
|
|
477
|
+
/* 커널이 담을 줄 몰라 세어 둔 사건·모르는 설비 보고 — 인스펙션이 이미 물을 자리다. */
|
|
478
|
+
unhandled: gap?.unhandled ?? [],
|
|
479
|
+
unknownEquipmentReports: gap?.unknownEquipmentReports ?? null,
|
|
480
|
+
notCovered: gap?.notCovered ?? []
|
|
330
481
|
}
|
|
331
482
|
: null
|
|
332
483
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nameIndex, namesFor } from './name-index.js'
|
|
1
2
|
import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
2
3
|
|
|
3
4
|
import { ScalarObject, getRepository } from '@things-factory/shell'
|
|
@@ -171,6 +172,12 @@ export class TwinModelTreeQuery {
|
|
|
171
172
|
items,
|
|
172
173
|
edges,
|
|
173
174
|
/* 수량을 하나라도 모르면 아래 합계가 성립하지 않는다는 사실을 그대로 낸다. */
|
|
175
|
+
/*
|
|
176
|
+
* **이름을 함께 낸다** (2026-08-22). 나무의 줄이 id 만 보이면 사용자는 그 노드가 무엇인지 알 수
|
|
177
|
+
* 없다 — 사용자의 말: 「각 노드의 식별자가 반복되어서 이해가 어렵다. 식별자와 이름이 보이면.」
|
|
178
|
+
* 목록·항목 화면이 쓰는 것과 **같은 표**를 쓴다(`name-index`) — 두 벌이면 한쪽만 고쳐진다.
|
|
179
|
+
*/
|
|
180
|
+
names: namesFor(items, nameIndex(inst.model ?? {})),
|
|
174
181
|
qtyIncomplete: edges.some(e => e.step == null),
|
|
175
182
|
cycles: [...new Set(cycles)],
|
|
176
183
|
truncated: { depth: depthTruncated, items: itemTruncated, depthCap, itemCap },
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Arg, Ctx, Query, Resolver } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { getRepository, ScalarObject } from '@things-factory/shell'
|
|
4
|
+
|
|
5
|
+
import { TwinEngine } from '../../engine/index.js'
|
|
6
|
+
import { assessSuite, SUITE_SPEC, type SuiteFacts } from '../../engine/integration-runner.js'
|
|
7
|
+
import { readSpecCoverage } from '../../engine/spec-coverage.js'
|
|
8
|
+
import { capabilitiesOf, getAdapter } from '../reference/reference-adapter.js'
|
|
9
|
+
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
10
|
+
import { anyJournalRow, countUpTo } from '../twin-event/journal-count.js'
|
|
11
|
+
import { TwinInstance } from '../twin-instance/twin-instance.js'
|
|
12
|
+
import { TwinReference } from '../reference/twin-reference.js'
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* 도입 단계 — **「이 트윈은 어디까지 증명됐나」.**
|
|
16
|
+
*
|
|
17
|
+
* `twinIngestHealth` 와 묻는 것이 다르다: 그쪽은 「지금 현장과 맞춰지고 있나」(피드의 건강)이고,
|
|
18
|
+
* 여기는 「구조·라이브·예측·폐루프 중 어디까지 **증거로** 섰나」다. 체크리스트가 아니라 판정이므로
|
|
19
|
+
* 사람이 체크해서 초록으로 만들 수 없다.
|
|
20
|
+
*
|
|
21
|
+
* **접속 프로브는 여기서 돌리지 않는다.** 조회마다 고객 시스템을 두드리면 화면을 열 때마다 남의
|
|
22
|
+
* 운영계에 부하가 간다. 그래서 `connectOk` 는 `null`(안 돌렸다)이고, 계획의 첫 칸이 접속 확인이 된다.
|
|
23
|
+
*/
|
|
24
|
+
@Resolver()
|
|
25
|
+
export class TwinReadinessQuery {
|
|
26
|
+
@Query(returns => ScalarObject, {
|
|
27
|
+
nullable: true,
|
|
28
|
+
description:
|
|
29
|
+
'Which adoption stage this twin has actually reached, judged from evidence rather than a checklist: structure, live, forecast, closed-loop. Each stage reports what blocks it and which signals could not be measured at all — an unmeasured signal never counts as achieved, so a half-run suite cannot claim green. Probes that do not apply to this twin (live probes on a simulation, mapping-warning probes on a template-built twin) come back as notApplicable with a reason, which is neither a pass nor a gap. `forbiddenClaims` lists the claims that must not be made yet (realtime, prediction, automatic control). The connection probe is not run here because a read query should not hit the source system on every page load, so it appears in `planned` as the next step to take. Null if the instance is not in the current domain.'
|
|
30
|
+
})
|
|
31
|
+
async twinAdoptionPath(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<any> {
|
|
32
|
+
const domainId = context.state.domain.id
|
|
33
|
+
if (!TwinEngine.owns(domainId, instanceId) && !(await TwinEngine.ownsRegistered(domainId, instanceId))) return null
|
|
34
|
+
|
|
35
|
+
const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
36
|
+
if (!inst) return null
|
|
37
|
+
const ref = await getRepository(TwinReference)
|
|
38
|
+
.findOne({ where: { domain: { id: domainId }, source: instanceId } })
|
|
39
|
+
.catch(() => null)
|
|
40
|
+
|
|
41
|
+
const mode = TwinEngine.modeOf(domainId, instanceId)
|
|
42
|
+
const snapshot = TwinEngine.owns(domainId, instanceId) ? TwinEngine.snapshot(domainId, instanceId) : undefined
|
|
43
|
+
const events = getRepository(TwinEvent)
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* 저널에서 센다 — **재시작에도 남는 자리**다. 인메모리 계기로 세면 재기동마다 0 이 되고, 그 0 이
|
|
47
|
+
* 「세었는데 반출이 없었다」로 읽혀 라이브 단계를 거짓으로 막는다.
|
|
48
|
+
*
|
|
49
|
+
* `action` 승격 전에 기록된 행은 세어지지 않는다. 그래서 반출이 **한 건도 없고** 승격 이후 유입도
|
|
50
|
+
* 없으면 `null`(모른다)로 낸다 — 옛 저널을 「반출 0건」으로 단정하지 않는다.
|
|
51
|
+
*/
|
|
52
|
+
/*
|
|
53
|
+
* **상한까지만 센다** (2026-08-22 실측). 예전에는 넷을 전부 세었고, 저널 358만 행인 트윈에서 한 번이
|
|
54
|
+
* 5.32초라 이 화면 하나가 20초를 썼다. 판정에 쓰이는 것은 문턱(≥1)이므로 상한까지의 수로 충분하고,
|
|
55
|
+
* 승격 여부는 **한 건만 찾으면** 답이 난다(수가 필요 없다).
|
|
56
|
+
*/
|
|
57
|
+
const scope = { domain: { id: domainId }, instanceId }
|
|
58
|
+
const journal = await countUpTo(events, scope)
|
|
59
|
+
const removalCount = await countUpTo(events, { ...scope, action: 'DELETE' })
|
|
60
|
+
const journalTotal = journal.count
|
|
61
|
+
const removals = removalCount.count
|
|
62
|
+
const anyPromoted =
|
|
63
|
+
removals > 0 ||
|
|
64
|
+
(await anyJournalRow(events, { ...scope, action: 'OBSERVE' })) ||
|
|
65
|
+
(await anyJournalRow(events, { ...scope, action: 'ADD' }))
|
|
66
|
+
|
|
67
|
+
const modelLocations = Array.isArray((inst.model as any)?.locations) ? (inst.model as any).locations.length : null
|
|
68
|
+
|
|
69
|
+
const facts: SuiteFacts = {
|
|
70
|
+
subject: mode === 'live' ? 'live' : mode === 'sim' ? 'sim' : 'unknown',
|
|
71
|
+
fromReference: !!ref,
|
|
72
|
+
adapterCapabilities: ref?.adapterType ? capabilitiesOf(getAdapter(ref.adapterType)) : [],
|
|
73
|
+
connectOk: null, // 조회에서 남의 운영계를 두드리지 않는다
|
|
74
|
+
/* 이 레퍼런스가 트윈을 세운 사이트 수 — 발견의 결과다(발견 자체를 다시 돌리지 않는다). */
|
|
75
|
+
siteCount: ref ? ((ref.scopeSpec as any)?.produced?.length ?? null) : null,
|
|
76
|
+
locationCount: snapshot?.locations?.length ?? modelLocations,
|
|
77
|
+
itemCount: snapshot?.items?.length ?? null,
|
|
78
|
+
lastWarningCount: ref?.lastWarningCount ?? null,
|
|
79
|
+
journalTotal,
|
|
80
|
+
/* 승격 이후 유입이 하나도 없으면 세지 못한 것이다 — 0 으로 단정하지 않는다. */
|
|
81
|
+
removalsJournaled: anyPromoted ? removals : null,
|
|
82
|
+
specCoverage: (() => {
|
|
83
|
+
const c: any = snapshot ? readSpecCoverage(snapshot) : null
|
|
84
|
+
if (!c?.operations?.length) return null
|
|
85
|
+
const total = c.operations.length
|
|
86
|
+
const declared = c.operations.filter((o: any) => o.duration === 'declared' || o.duration === 'measured').length
|
|
87
|
+
return { declared, total }
|
|
88
|
+
})(),
|
|
89
|
+
forecastRan: null, // 예측 실행 이력을 남기는 자리가 아직 없다 — 모른다고 말한다
|
|
90
|
+
feed: 'unknown'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const assessment = assessSuite(facts)
|
|
94
|
+
return { instanceId, ...assessment, spec: SUITE_SPEC, facts }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -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 { ScalarObject, getRepository } from '@things-factory/shell'
|
|
@@ -102,9 +103,9 @@ export class TwinSpaceResolver {
|
|
|
102
103
|
* 공간 삭제 — **이 공간의 트윈이 있으면 거절한다.** 그래서 트윈이 현장을 잃는 일은 없다.
|
|
103
104
|
*
|
|
104
105
|
* ── 왜 모양을 바꿨나 (2026-08-13) ────────────────────────────────────────
|
|
105
|
-
* 예전에는 `Boolean` 을 돌려주고 거절은 **한국어 문장을
|
|
106
|
+
* 예전에는 `Boolean` 을 돌려주고 거절은 **한국어 문장을 냈다.** 두 가지가 틀렸다:
|
|
106
107
|
* ① 서버가 화면의 말을 정했다 — 일본어·스페인어 사용자에게 한국어가 새어 나간다.
|
|
107
|
-
* ②
|
|
108
|
+
* ② 오류를 낸 예외를 화면이 받지 않아 **아무 일도 없는 것처럼** 보였다. 사용자는 삭제를 눌렀고
|
|
108
109
|
* 화면은 그대로였다. 거절을 조용히 하는 것은 실패보다 나쁘다.
|
|
109
110
|
* 이제 `{ok, errorCode, errorParams}` 로 낸다 — 화면이 `twin.compose.del.<code>` 로 말을 만든다.
|
|
110
111
|
*/
|
|
@@ -220,7 +221,7 @@ export class TwinSpaceResolver {
|
|
|
220
221
|
} as any)
|
|
221
222
|
)
|
|
222
223
|
} catch (err) {
|
|
223
|
-
|
|
224
|
+
twinError(`[twin-space] move audit not recorded for "${instanceId}"`, err)
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
return { ok: true, warnings: judgement.warnings }
|
|
@@ -285,7 +286,7 @@ export class TwinSpaceResolver {
|
|
|
285
286
|
} as any)
|
|
286
287
|
)
|
|
287
288
|
} catch (err) {
|
|
288
|
-
|
|
289
|
+
twinError(`[twin-space] detach audit not recorded for "${instanceId}"`, err)
|
|
289
290
|
}
|
|
290
291
|
|
|
291
292
|
return { ok: true, warnings: judgement.warnings }
|
package/shared/entity-delta.ts
CHANGED
|
@@ -72,7 +72,7 @@ const EXCLUDED = new Set(['id', 'epc', 'oee'])
|
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트.
|
|
75
|
-
* 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초
|
|
75
|
+
* 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초 재브로드캐스팅 억제).
|
|
76
76
|
* oee 미측정이면 undefined.
|
|
77
77
|
*/
|
|
78
78
|
function shapeOee(o: any): any {
|
|
@@ -119,11 +119,11 @@ function aggregateItem(entity: any, idKey: 'id' | 'epc'): any {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
* 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매
|
|
122
|
+
* 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 브로드캐스팅에 실으면 전달이 현장을 느리게 한다.
|
|
123
123
|
*
|
|
124
124
|
* **조용히 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가
|
|
125
125
|
* "이게 전부" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup
|
|
126
|
-
* 이 걸려 **변한 것만**
|
|
126
|
+
* 이 걸려 **변한 것만** 재브로드캐스팅된다.
|
|
127
127
|
*/
|
|
128
128
|
export const ITEMS_AGGREGATE_LIMIT = 500
|
|
129
129
|
|
|
@@ -171,7 +171,28 @@ function energyByLocation(state: any): Map<string, Record<string, unknown>> {
|
|
|
171
171
|
return out
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
/**
|
|
175
|
+
* 물품 축만 **범위를 줄일 수 있다** (2026-08-21).
|
|
176
|
+
*
|
|
177
|
+
* ── 왜 이 인자가 생겼나 ─────────────────────────────────────────────────────
|
|
178
|
+
* 브로드캐스팅 한 번이 만드는 payload 는 엔티티 수만큼이고 그 대부분이 물품이다(실측: 엔티티 3,611 중
|
|
179
|
+
* 물품 2,400 · 만드는 데 1.8ms · 문자열로 바꾸는 데 1.0ms). 바뀐 것이 물품 하나여도 전부 만들어
|
|
180
|
+
* 「같다」를 확인하고 버렸다.
|
|
181
|
+
*
|
|
182
|
+
* `items` 를 주면 **그 태그의 물품만** 개별 payload 를 만든다. 주지 않으면(`undefined`) 전부 만든다 —
|
|
183
|
+
* 부르는 쪽이 무엇이 바뀌었는지 말할 수 없을 때의 답이다.
|
|
184
|
+
*
|
|
185
|
+
* **집약 뷰는 범위를 받지 않는다.** 집약은 목록 전체가 한 payload 라서, 한 물품이 바뀌면 그 payload 는
|
|
186
|
+
* 통째로 새로 만들어야 한다(상한 500으로 이미 묶여 있다). 자리·설비·인원·자산·작업·오더도 범위를 받지
|
|
187
|
+
* 않는다 — 수가 적고, 잘못 걸러서 잃는 것이 크다(자리 점유는 물품이 떠난 자리와 도착한 자리가 함께
|
|
188
|
+
* 바뀌는데 사건은 한 지점만 말한다).
|
|
189
|
+
*/
|
|
190
|
+
export interface EntityDeltaScope {
|
|
191
|
+
/** 개별 payload 를 만들 물품 태그. 없으면 전부. */
|
|
192
|
+
items?: ReadonlySet<string>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function buildEntityDeltas(state: any, instanceId: string, scope?: EntityDeltaScope): EntityDelta[] {
|
|
175
196
|
const out: EntityDelta[] = []
|
|
176
197
|
const locEnergy = energyByLocation(state)
|
|
177
198
|
|
|
@@ -203,8 +224,11 @@ export function buildEntityDeltas(state: any, instanceId: string): EntityDelta[]
|
|
|
203
224
|
out.push({ tag: t.id, data: payloadOf('task', t) })
|
|
204
225
|
}
|
|
205
226
|
for (const it of state?.items ?? []) {
|
|
206
|
-
/* 물품의 태그는 epc(개체 식별자) —
|
|
207
|
-
if (it?.epc)
|
|
227
|
+
/* 물품의 태그는 epc(개체 식별자) — 자리·설비와 달리 id 가 아니다. */
|
|
228
|
+
if (!it?.epc) continue
|
|
229
|
+
/* 범위가 주어졌으면 그 안의 물품만 만든다 — 나머지는 값이 그대로이므로 보낼 것이 없다. */
|
|
230
|
+
if (scope?.items && !scope.items.has(it.epc)) continue
|
|
231
|
+
out.push({ tag: it.epc, data: payloadOf('item', it) })
|
|
208
232
|
}
|
|
209
233
|
|
|
210
234
|
/* ── 집약 뷰 ──────────────────────────────────────────────────────────────
|
|
@@ -274,7 +298,7 @@ export function buildEntityDeltas(state: any, instanceId: string): EntityDelta[]
|
|
|
274
298
|
...(energy.contractKW !== undefined ? { contractKW: energy.contractKW } : {}),
|
|
275
299
|
...(energy.open ? { open: energy.open } : {}),
|
|
276
300
|
...(energy.peakSince ? { peakSince: energy.peakSince } : {}),
|
|
277
|
-
/* 마감된 구간은 **최근 것만** 낸다 — 하루치를 매
|
|
301
|
+
/* 마감된 구간은 **최근 것만** 낸다 — 하루치를 매 브로드캐스팅에 실으면 채널이 그것으로 찬다.
|
|
278
302
|
전체 수는 함께 낸다(조용히 자르지 않는다). */
|
|
279
303
|
recent: (energy.closed ?? []).slice(-8),
|
|
280
304
|
closedTotal: energy.closedTotal ?? 0
|
package/shared/kpi-broadcast.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 구간 성과 카드의 **대상 식별자**.
|
|
3
3
|
*
|
|
4
|
-
* 예전에는 이 파일이
|
|
4
|
+
* 예전에는 이 파일이 브로드캐스팅 payload 도 만들었다(`shapeKpiBroadcast`). 2026-08-06 에 브로드캐스팅을 없애고 카드가
|
|
5
5
|
* `twinKpi` 를 직접 묻게 바꿔서, 남은 것은 팔레트가 대상을 가리킬 때 쓰는 식별자뿐이다.
|
|
6
6
|
* 왜 바꿨는지는 `twin-engine.ts` 의 주석과 `operato-twin/client/scene/twin-kpi-source.ts` 에 있다.
|
|
7
7
|
*/
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 한 사건이 **어느 물품을 건드렸나** — 브로드캐스팅이 바뀐 것만 만들 수 있게. 순수(import 없음).
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 물품만인가 (2026-08-21 실측) ─────────────────────────────────────────
|
|
5
|
+
* 브로드캐스팅 한 번이 만드는 payload 는 엔티티 수만큼이고, 그 대부분이 물품이다(실측: 엔티티 3,611 중
|
|
6
|
+
* 물품 2,400). 자리·설비·인원·자산은 모델이 정한 수라 적고(자리 최대 200, 평균 19), 작업·오더는 수백이다.
|
|
7
|
+
* 그래서 **물품 축만 걸러도** 만드는 양의 3분의 2가 사라진다.
|
|
8
|
+
*
|
|
9
|
+
* 나머지 축은 **매번 전부 만든다.** 걸러서 얻는 것이 적고, 잘못 걸러서 잃는 것이 크기 때문이다 —
|
|
10
|
+
* 예를 들어 물품이 한 자리에서 다른 자리로 가면 **두 자리의 점유가 함께 바뀌는데** EPCIS 사건은 읽은
|
|
11
|
+
* 지점 하나만 말한다. 그것으로 자리를 걸렀으면 떠난 자리의 점유가 낡은 값으로 남는다(오류 없이).
|
|
12
|
+
*
|
|
13
|
+
* ── 모르면 전부다 ───────────────────────────────────────────────────────────
|
|
14
|
+
* 이 함수가 `null` 을 돌려주면 「이 사건이 어느 물품을 건드렸는지 말할 수 없다」는 뜻이고, 그때 호출자는
|
|
15
|
+
* **전부 다시 만든다**(지금까지의 거동). 낯선 어휘가 오면 조용히 빠뜨리는 것이 아니라 비싸게 안전한
|
|
16
|
+
* 쪽으로 떨어진다 — 화면이 낡는 것보다 계산을 한 번 더 하는 것이 낫다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 물품 태그는 개체 식별자(epc)다 — 자리·설비와 달리 id 가 아니다(entity-delta 와 같은 규약). */
|
|
20
|
+
function push(out: Set<string>, v: unknown): void {
|
|
21
|
+
if (typeof v === 'string' && v) out.add(v)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 물품을 건드리지 않는다고 **선언된** 사건들 — 커널의 운영 델타와 에너지 사건.
|
|
26
|
+
*
|
|
27
|
+
* 이 목록은 커널 계약(`OP_EVENT`·`ENERGY_EVENT`)에서 왔다. 여기 없는 낱말이 오면 「모른다」로 떨어져
|
|
28
|
+
* 전부 다시 만든다 — 커널이 사건을 늘렸을 때 조용히 빠뜨리는 대신 비싸게 안전한 쪽을 고른다.
|
|
29
|
+
*
|
|
30
|
+
* 근거: 미러의 리듀서에서 이 사건들은 작업·설비·인원·자산·오더·계량만 바꾼다(물품 축을 만지지 않는다).
|
|
31
|
+
* 자리 점유는 이 사건들도 바꾸지만, 자리는 **어차피 매번 전부 다시 만든다**(위 주석).
|
|
32
|
+
*/
|
|
33
|
+
const ITEM_FREE_EVENTS = new Set([
|
|
34
|
+
'task.status',
|
|
35
|
+
'equipment.status',
|
|
36
|
+
'person.status',
|
|
37
|
+
'asset.status',
|
|
38
|
+
'order.status',
|
|
39
|
+
'quality.output',
|
|
40
|
+
'attention.acked'
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 이 사건이 건드린 물품 태그들. **말할 수 없으면 `null`** — 호출자는 그때 전부 다시 만든다.
|
|
45
|
+
*
|
|
46
|
+
* 개체를 말하는 자리를 전부 본다: `epcList`(관측·커미셔닝) · `parentID`(집약의 부모) ·
|
|
47
|
+
* `childEPCs`(집약의 자식) · `inputEPCList`·`outputEPCList`(변환의 입출력).
|
|
48
|
+
*/
|
|
49
|
+
export function touchedItemKeys(envelope: any): Set<string> | null {
|
|
50
|
+
const type = envelope?.eventType
|
|
51
|
+
if (typeof type !== 'string') return null
|
|
52
|
+
const d = envelope?.data ?? envelope
|
|
53
|
+
if (!d || typeof d !== 'object') return null
|
|
54
|
+
|
|
55
|
+
if (type.startsWith('energy.') || ITEM_FREE_EVENTS.has(type)) return new Set()
|
|
56
|
+
/* EPCIS 밖의 낯선 낱말 — 무엇을 바꿨는지 우리가 모른다. */
|
|
57
|
+
if (!type.startsWith('epcis.')) return null
|
|
58
|
+
|
|
59
|
+
const out = new Set<string>()
|
|
60
|
+
for (const v of d.epcList ?? []) push(out, v)
|
|
61
|
+
for (const v of d.childEPCs ?? []) push(out, v)
|
|
62
|
+
for (const v of d.inputEPCList ?? []) push(out, v)
|
|
63
|
+
for (const v of d.outputEPCList ?? []) push(out, v)
|
|
64
|
+
push(out, d.parentID)
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* 개체를 하나도 말하지 않은 EPCIS 사건은 **부류 수량**으로 말한 것이다(`quantityList`). 그 부류의
|
|
68
|
+
* 물품이 어느 것인지는 여기서 알 수 없으므로 전부 다시 만든다.
|
|
69
|
+
*/
|
|
70
|
+
if (!out.size) return null
|
|
71
|
+
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 상태가 빈 축의 이유 — **「아직 없다」와 「왔는데 상태에 없다」를 가른다** (2026-08-22).
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 있었나 ───────────────────────────────────────────────────────────
|
|
5
|
+
* 카드가 「작업 — 아직 하나도 없습니다」라고 말했다. 그 트윈의 저널에는 `task.status` 5,786건이 있었고
|
|
6
|
+
* **마지막 상태가 `in-progress`** 였다. 사용자가 「작업 지시 835건인데 작업이 0인 이유」를 물었고,
|
|
7
|
+
* 원인은 관측 모드 커널이 관측기의 작업을 스냅샷으로 내보내지 않는 것이었다(커널 자리, 그 레인에 넘겼다).
|
|
8
|
+
*
|
|
9
|
+
* **화면 몫은 단정하지 않는 것이다.** 「아직 하나도 없습니다」는 「선언은 있고 사실이 아직 일어나지
|
|
10
|
+
* 않았다」는 뜻인데 실제로는 「사실이 일어났는데 상태에 오지 못했다」였다. 그 차이를 화면이 말하지
|
|
11
|
+
* 않아서 사용자가 직접 조사해야 했다.
|
|
12
|
+
*
|
|
13
|
+
* 이 시험이 지키는 것:
|
|
14
|
+
* ① 저널에 그 축의 사건이 있으면 **그렇게 말한다**.
|
|
15
|
+
* ② 없으면 예전 그대로 「아직 없다」다.
|
|
16
|
+
* ③ 판단할 수 없는 축은 **아무 말도 만들지 않는다**(`null`) — 모르는 것을 새 사실로 바꾸지 않는다.
|
|
17
|
+
* ④ 한 건만 묻는다 — 세는 것도 비용이다(저널 358만 행에서 전량 세기는 5.32초였다).
|
|
18
|
+
*/
|
|
19
|
+
import { test } from 'node:test'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
21
|
+
|
|
22
|
+
import { axisSeenInJournal } from '../server/service/twin-model/axis-journal-evidence.ts'
|
|
23
|
+
|
|
24
|
+
/** 저장소 흉내 — 어떤 조건으로 몇 번 물었는지 기록한다. */
|
|
25
|
+
function repoWith(present: string[]) {
|
|
26
|
+
const asked: any[] = []
|
|
27
|
+
return {
|
|
28
|
+
asked,
|
|
29
|
+
async find(opts: any) {
|
|
30
|
+
asked.push(opts)
|
|
31
|
+
const t = opts?.where?.eventType
|
|
32
|
+
const hit =
|
|
33
|
+
typeof t === 'string'
|
|
34
|
+
? present.includes(t)
|
|
35
|
+
: /* Like(…) 객체 — 접두사로 본다 */
|
|
36
|
+
present.some(p => p.startsWith(String(t?._value ?? '').replace('%', '')))
|
|
37
|
+
return hit ? [{ id: 'x' }] : []
|
|
38
|
+
}
|
|
39
|
+
} as any
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const WHERE = { domain: { id: 'd' }, instanceId: 'twin-1' }
|
|
43
|
+
|
|
44
|
+
test('저널에 그 축의 사건이 있으면 그렇게 말한다 — 작업', async () => {
|
|
45
|
+
const repo = repoWith(['task.status'])
|
|
46
|
+
assert.equal(await axisSeenInJournal(repo, WHERE, 'tasks'), true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('없으면 예전 그대로 — 아직 없다', async () => {
|
|
50
|
+
const repo = repoWith(['order.status'])
|
|
51
|
+
assert.equal(await axisSeenInJournal(repo, WHERE, 'tasks'), false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('물품은 사건 이름이 여럿이라 접두사로 본다 — 커널이 이름을 늘려도 낡지 않게', async () => {
|
|
55
|
+
const repo = repoWith(['epcis.AggregationEvent'])
|
|
56
|
+
assert.equal(await axisSeenInJournal(repo, WHERE, 'items'), true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('판단할 수 없는 축은 아무 말도 만들지 않는다', async () => {
|
|
60
|
+
const repo = repoWith(['task.status'])
|
|
61
|
+
assert.equal(await axisSeenInJournal(repo, WHERE, 'locations'), null, '표에 없는 축은 모른다')
|
|
62
|
+
assert.equal(repo.asked.length, 0, '모르는 축을 두고 저널을 묻지 않는다')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('한 건만 묻는다 — 세는 것도 비용이다', async () => {
|
|
66
|
+
const repo = repoWith(['task.status'])
|
|
67
|
+
await axisSeenInJournal(repo, WHERE, 'tasks')
|
|
68
|
+
assert.equal(repo.asked.length, 1)
|
|
69
|
+
assert.equal(repo.asked[0].take, 1, '한 건만 읽는다')
|
|
70
|
+
assert.deepEqual(repo.asked[0].select, ['id'], '키 컬럼만 읽는다(행 본문을 만들지 않는다)')
|
|
71
|
+
})
|