@things-factory/headless-twin 10.0.7 → 10.0.9
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/attention-digest.d.ts +52 -0
- package/dist-server/engine/attention-digest.js +76 -0
- package/dist-server/engine/attention-digest.js.map +1 -0
- package/dist-server/engine/board-vocabulary.d.ts +17 -0
- package/dist-server/engine/board-vocabulary.js +63 -0
- package/dist-server/engine/board-vocabulary.js.map +1 -0
- package/dist-server/engine/command-routing.d.ts +33 -0
- package/dist-server/engine/command-routing.js +53 -0
- package/dist-server/engine/command-routing.js.map +1 -0
- package/dist-server/engine/entity-delta.d.ts +13 -6
- package/dist-server/engine/entity-delta.js +38 -12
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +6 -0
- package/dist-server/engine/index.js +10 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-baseline.d.ts +78 -0
- package/dist-server/engine/kpi-baseline.js +123 -0
- package/dist-server/engine/kpi-baseline.js.map +1 -0
- package/dist-server/engine/kpi-broadcast.d.ts +4 -0
- package/dist-server/engine/kpi-broadcast.js +16 -0
- package/dist-server/engine/kpi-broadcast.js.map +1 -0
- package/dist-server/engine/kpi-query.d.ts +31 -0
- package/dist-server/engine/kpi-query.js +50 -2
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-attentions.d.ts +1 -0
- package/dist-server/engine/live-attentions.js +7 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/runtime-key.d.ts +15 -0
- package/dist-server/engine/runtime-key.js +64 -0
- package/dist-server/engine/runtime-key.js.map +1 -0
- package/dist-server/engine/state-axes.d.ts +16 -0
- package/dist-server/engine/state-axes.js +54 -0
- package/dist-server/engine/state-axes.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +92 -16
- package/dist-server/engine/twin-engine.js +335 -77
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/twin-level.d.ts +23 -0
- package/dist-server/engine/twin-level.js +52 -0
- package/dist-server/engine/twin-level.js.map +1 -0
- package/dist-server/engine/warm-start.d.ts +58 -12
- package/dist-server/engine/warm-start.js +80 -9
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/service/reference/discovery-result.d.ts +34 -0
- package/dist-server/service/reference/discovery-result.js +84 -0
- package/dist-server/service/reference/discovery-result.js.map +1 -0
- package/dist-server/service/reference/ingest-space.d.ts +30 -0
- package/dist-server/service/reference/ingest-space.js +63 -0
- package/dist-server/service/reference/ingest-space.js.map +1 -0
- package/dist-server/service/reference/knob-defaults.d.ts +20 -0
- package/dist-server/service/reference/knob-defaults.js +59 -0
- package/dist-server/service/reference/knob-defaults.js.map +1 -0
- package/dist-server/service/reference/reference-live.js +2 -2
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +37 -2
- package/dist-server/service/reference/reference-master.js +55 -5
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +2 -2
- package/dist-server/service/reference/reference-resolver.js +71 -15
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
- package/dist-server/service/twin-control/twin-control-mutation.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-instance/twin-instance.js +4 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
- package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
- package/dist-server/service/twin-structure/twin-structure.js +2 -1
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
- package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/attention-digest.ts +102 -0
- package/server/engine/board-vocabulary.ts +61 -0
- package/server/engine/command-routing.ts +67 -0
- package/server/engine/entity-delta.ts +33 -11
- package/server/engine/index.ts +10 -0
- package/server/engine/kpi-baseline.ts +202 -0
- package/server/engine/kpi-broadcast.ts +13 -0
- package/server/engine/kpi-query.ts +81 -3
- package/server/engine/live-attentions.ts +7 -2
- package/server/engine/runtime-key.ts +58 -0
- package/server/engine/state-axes.ts +55 -0
- package/server/engine/twin-engine.ts +350 -77
- package/server/engine/twin-level.ts +48 -0
- package/server/engine/warm-start.ts +130 -16
- package/server/service/reference/discovery-result.ts +95 -0
- package/server/service/reference/ingest-space.ts +70 -0
- package/server/service/reference/knob-defaults.ts +59 -0
- package/server/service/reference/reference-live.ts +2 -2
- package/server/service/reference/reference-master.ts +94 -8
- package/server/service/reference/reference-resolver.ts +80 -16
- package/server/service/twin-attention/twin-attention-query.ts +43 -6
- package/server/service/twin-control/twin-control-mutation.ts +31 -12
- package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
- package/server/service/twin-instance/twin-instance.ts +6 -2
- package/server/service/twin-journal/twin-journal-query.ts +35 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
- package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +10 -1
- package/server/service/twin-state/twin-state-subscription.ts +1 -1
- package/server/service/twin-structure/twin-structure.ts +4 -1
- package/server/service/twin-target/twin-target-resolver.ts +22 -4
- package/test/attention-digest.test.ts +135 -0
- package/test/board-vocabulary.test.ts +114 -0
- package/test/capability-mapping.test.ts +4 -4
- package/test/command-routing.test.ts +61 -0
- package/test/discovery-result.test.ts +75 -0
- package/test/entity-delta.test.ts +25 -25
- package/test/ingest-bench.test.ts +3 -3
- package/test/ingest-space.test.ts +50 -0
- package/test/knob-defaults.test.ts +72 -0
- package/test/kpi-baseline-db.test.ts +214 -0
- package/test/kpi-baseline.test.ts +196 -0
- package/test/kpi-query-bench.test.ts +128 -0
- package/test/live-mirror-parity.test.ts +35 -2
- package/test/master-to-twin.test.ts +7 -3
- package/test/mutation-gate.test.ts +108 -0
- package/test/oee-accumulator.test.ts +65 -1
- package/test/registry-key-guard.test.ts +80 -0
- package/test/runtime-key.test.ts +66 -0
- package/test/scale-twin-bench.test.ts +2 -2
- package/test/state-axes.test.ts +74 -0
- package/test/streamline-e2e.test.ts +2 -2
- package/test/structure-revision-db.test.ts +5 -4
- package/test/tenant-registry-db.test.ts +149 -0
- package/test/warm-start-seam.test.ts +140 -0
- package/test/warm-start.test.ts +138 -4
|
@@ -25,7 +25,7 @@ import { TwinInstance } from '../service/twin-instance/twin-instance.js'
|
|
|
25
25
|
import { TwinArea } from '../service/twin-space/twin-area.js'
|
|
26
26
|
import { TwinSpace } from '../service/twin-space/twin-space.js'
|
|
27
27
|
import { TwinTarget } from '../service/twin-target/twin-target.js'
|
|
28
|
-
import { judgeTargets, judgeGroupTargets, scopedTargets, type ScopedTargets, type TargetVerdict } from './kpi-target.js'
|
|
28
|
+
import { TWIN_METRIC, judgeTargets, judgeGroupTargets, scopedTargets, type ScopedTargets, type TargetVerdict, type TwinMetricId } from './kpi-target.js'
|
|
29
29
|
import { TwinEngine } from './twin-engine.js'
|
|
30
30
|
import {
|
|
31
31
|
crossGroups,
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type KpiGroupBy,
|
|
39
39
|
type KpiResult
|
|
40
40
|
} from './kpi-fold.js'
|
|
41
|
+
import { MAX_BASELINE_PERIODS, baselineWindows, summarizeBaseline, type BaselineStride, type MetricBaseline } from './kpi-baseline.js'
|
|
41
42
|
|
|
42
43
|
/** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */
|
|
43
44
|
const BUSINESS_EVENTS = ['task.status', 'order.status']
|
|
@@ -87,6 +88,18 @@ export interface TwinKpiInput {
|
|
|
87
88
|
* 없는 것을 0% 로 그리면 "변화 없음" 이라는 거짓이 된다.
|
|
88
89
|
*/
|
|
89
90
|
compareTo?: 'previous' | 'yesterday'
|
|
91
|
+
/**
|
|
92
|
+
* **여러 구간 동시 비교** — 최근 N 구간의 같은 시간대를 함께 접어 **분포**와 그 안의 자리를 낸다.
|
|
93
|
+
*
|
|
94
|
+
* `compareTo` 는 한 구간만 봐서 판단이 "어제보다 나아졌다" 에서 멈춘다 — 어제도 나빴으면 쓸모가 없다.
|
|
95
|
+
* 최근 며칠의 같은 시간대를 보면 목표 없이도 "지난 7일 중 가장 낮다" 를 말할 수 있다.
|
|
96
|
+
*
|
|
97
|
+
* `stride: 'week'` 는 같은 요일을 본다(주말·평일이 섞이지 않게). `periods` 는 1~14
|
|
98
|
+
* (창마다 조회가 한 번 더 붙는다 — 값을 공짜로 말하지 않는다).
|
|
99
|
+
*
|
|
100
|
+
* `compareTo` 와 함께 쓸 수 있다: 다른 질문이기 때문이다(직전 한 구간 vs 최근 분포).
|
|
101
|
+
*/
|
|
102
|
+
baseline?: { periods: number; stride?: BaselineStride }
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
|
|
@@ -147,6 +160,21 @@ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
|
|
|
147
160
|
waitTime?: KpiResult['waitTime']
|
|
148
161
|
delta?: { tasks: number; orders: number; workP50Ms?: number; leadP50Ms?: number; waitP50Ms?: number }
|
|
149
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* **여러 구간 분포**(요청했을 때만) — 지표별로 최근 N 구간의 분포와 지금의 자리.
|
|
165
|
+
*
|
|
166
|
+
* `samples` 는 **요청한 창 전부**다(기록 없는 창도 `measured:false` 로 남는다) — 빼고 말하면
|
|
167
|
+
* "지난 7일" 이 실제로는 3일이었다는 것을 아무도 모른다. 자리를 말할 수 없으면 `noPosition` 이
|
|
168
|
+
* 이유를 밝힌다(표본 부족·현재 값 없음). 방향은 지표의 성질이라 `standing` 이 그것을 적용한 읽기다.
|
|
169
|
+
*/
|
|
170
|
+
baseline?: {
|
|
171
|
+
stride: BaselineStride
|
|
172
|
+
/** 요청한 과거 구간 수(상한 적용 후). */
|
|
173
|
+
periods: number
|
|
174
|
+
metrics: MetricBaseline[]
|
|
175
|
+
/** 과거 창 조회 중 하나라도 상한에 걸렸나 — 그 창의 값은 하한이라 분포가 낮게 보일 수 있다. */
|
|
176
|
+
capped?: boolean
|
|
177
|
+
}
|
|
150
178
|
/**
|
|
151
179
|
* 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이
|
|
152
180
|
* 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다.
|
|
@@ -411,12 +439,49 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
411
439
|
minutes: Math.round((toMs - fromMs) / 60_000),
|
|
412
440
|
basis
|
|
413
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* 여러 구간 분포를 만든다 — **현재 창에 기록이 없어도 낸다.**
|
|
444
|
+
*
|
|
445
|
+
* 그때가 오히려 가장 알고 싶은 순간이다("지금 아무 기록이 없는데 평소엔 60건이었다" = 멈춘 신호).
|
|
446
|
+
* 그래서 조기 반환보다 앞에 두고 두 경로가 함께 쓴다.
|
|
447
|
+
*/
|
|
448
|
+
const computeBaseline = async (): Promise<TwinKpiOutput['baseline']> => {
|
|
449
|
+
if (!input.baseline || !(input.baseline.periods > 0)) return undefined
|
|
450
|
+
const stride = input.baseline.stride ?? 'day'
|
|
451
|
+
const windows = baselineWindows(fromMs, toMs, input.baseline.periods, stride)
|
|
452
|
+
let baseCapped = false
|
|
453
|
+
const samples: { fromMs: number; toMs: number; values: Partial<Record<TwinMetricId, number | undefined>> }[] = []
|
|
454
|
+
|
|
455
|
+
for (const w of windows) {
|
|
456
|
+
const { events: pastEvents, capped: pastCapped } = await fetchEvents(input.domainId, instanceIds, w.fromMs, w.toMs, lookbackMs)
|
|
457
|
+
baseCapped = baseCapped || pastCapped
|
|
458
|
+
/* 축 없이, **같은 교대 정의**로 접는다(정의가 다르면 같은 시간대가 아니게 된다). */
|
|
459
|
+
const past = foldKpi(pastEvents, w, { locationArea: areaMap.locationArea, ...shiftCtx })
|
|
460
|
+
const values: Partial<Record<TwinMetricId, number | undefined>> = {}
|
|
461
|
+
for (const metric of Object.keys(TWIN_METRIC) as TwinMetricId[]) values[metric] = TWIN_METRIC[metric].of(past)
|
|
462
|
+
samples.push({ fromMs: w.fromMs, toMs: w.toMs, values })
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const current: Partial<Record<TwinMetricId, number | undefined>> = {}
|
|
466
|
+
for (const metric of Object.keys(TWIN_METRIC) as TwinMetricId[]) current[metric] = TWIN_METRIC[metric].of(kpi)
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
stride,
|
|
470
|
+
periods: windows.length,
|
|
471
|
+
metrics: summarizeBaseline(TWIN_METRIC, current, samples),
|
|
472
|
+
...(baseCapped ? { capped: true } : {})
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
414
477
|
if (kpi.eventsInWindow === 0) {
|
|
478
|
+
const baseline = await computeBaseline()
|
|
415
479
|
return {
|
|
416
480
|
window,
|
|
417
481
|
measured: false,
|
|
418
482
|
scope,
|
|
419
|
-
note: 'no operational events in this window — nothing to measure (this is NOT "zero throughput").'
|
|
483
|
+
note: 'no operational events in this window — nothing to measure (this is NOT "zero throughput").',
|
|
484
|
+
...(baseline ? { baseline } : {})
|
|
420
485
|
}
|
|
421
486
|
}
|
|
422
487
|
|
|
@@ -485,6 +550,15 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
485
550
|
}
|
|
486
551
|
}
|
|
487
552
|
|
|
553
|
+
/*
|
|
554
|
+
* 여러 구간 동시 비교 — **같은 시간대를 며칠 뒤로** 옮겨 분포를 만든다(성과 맵 ③).
|
|
555
|
+
*
|
|
556
|
+
* 창마다 조회가 한 번 더 붙지만 각 창은 현재 창과 같은 길이라(보통 1~24h) 값싸다. 축(groupBy)은
|
|
557
|
+
* 넘기지 않는다 — 분포는 **전체 지표**의 질문이고, 축별 분포까지 내면 응답이 축 수만큼 부푼다
|
|
558
|
+
* (필요해지면 그때 축을 지정해 다시 묻는 것이 정직하다).
|
|
559
|
+
*
|
|
560
|
+
* 지표 어휘는 `TWIN_METRIC` 이 계속 소유하고, 여기서는 **값만 꺼내** 분포 모듈에 넘긴다.
|
|
561
|
+
*/
|
|
488
562
|
/* 관점 값은 시각 변환이 없어 그대로 통과한다. 붙이는 것은 ① 사람이 읽는 이름과 ② 축이 통째로
|
|
489
563
|
* 비었을 때의 이유다 — "구역별 0건" 과 "노드에 구역이 안 붙어 있다" 는 완전히 다른 상황이다. */
|
|
490
564
|
const groups = kpi.groups
|
|
@@ -529,6 +603,9 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
529
603
|
* **목표가 없으면 판정하지 않는다** — 필드 자체를 만들지 않는다. 전부 "목표 없음" 으로 채우면
|
|
530
604
|
* 화면이 회색 노이즈가 되고, 목표가 있는 지표가 묻힌다.
|
|
531
605
|
*/
|
|
606
|
+
/* 분포는 **한 번만** 계산한다 — 창마다 조회가 붙으므로 두 번 부르면 조회가 두 배가 된다. */
|
|
607
|
+
const baseline = await computeBaseline()
|
|
608
|
+
|
|
532
609
|
/* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */
|
|
533
610
|
const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi
|
|
534
611
|
return {
|
|
@@ -540,6 +617,7 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
540
617
|
...(buckets ? { buckets } : {}),
|
|
541
618
|
...(groups ? { groups } : {}),
|
|
542
619
|
...(verdicts.length ? { targets: verdicts } : {}),
|
|
543
|
-
...(comparison ? { comparison } : {})
|
|
620
|
+
...(comparison ? { comparison } : {}),
|
|
621
|
+
...(baseline ? { baseline } : {})
|
|
544
622
|
}
|
|
545
623
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { deriveAttentions } from '@operato/twin-kernel'
|
|
8
8
|
|
|
9
|
-
export function withLiveAttentions<S extends { equipment?: any[]; locations?: any[]; orders?: any[]; tasks?: any[]; attentions?: any[]; nowTime?: string }>(
|
|
9
|
+
export function withLiveAttentions<S extends { equipment?: any[]; locations?: any[]; orders?: any[]; tasks?: any[]; attentions?: any[]; acked?: string[]; nowTime?: string }>(
|
|
10
10
|
state: S
|
|
11
11
|
): S {
|
|
12
12
|
if (!state) return state
|
|
@@ -18,7 +18,12 @@ export function withLiveAttentions<S extends { equipment?: any[]; locations?: an
|
|
|
18
18
|
*/
|
|
19
19
|
attentions: deriveAttentions(
|
|
20
20
|
{ equipment: state.equipment ?? [], locations: state.locations ?? [], orders: state.orders ?? [], tasks: state.tasks ?? [] },
|
|
21
|
-
|
|
21
|
+
/*
|
|
22
|
+
* **확인(ack)해 둔 것을 함께 넘긴다.** 예전에는 여기에 undefined 를 넘겨서, 라이브에서는
|
|
23
|
+
* 확인 처리가 화면에 전혀 반영되지 않았다 — 사람이 확인해도 신호가 계속 활성으로 나갔다.
|
|
24
|
+
* 확인 사실은 이제 저널에 남고(`attention.acked`) 프로젝터가 상태에 실어 오므로 여기서 받는다.
|
|
25
|
+
*/
|
|
26
|
+
state.acked?.length ? new Set(state.acked) : undefined,
|
|
22
27
|
state.nowTime
|
|
23
28
|
)
|
|
24
29
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 런타임 레지스트리 키 — **트윈의 정체성은 `(도메인, 인스턴스 id)` 다.** 순수.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 있나 (2026-08-06 테넌트 격리 전수 확인) ───────────────────────────────
|
|
5
|
+
* `TwinEngine.instances` 와 `recovered` 가 **`instanceId` 하나로** 키를 잡고 있었다. 그런데 DB 유일성은
|
|
6
|
+
* `[domain, instanceId]` 이고(`TwinInstance` 인덱스) 트윈의 정체성도 그쪽이다. 즉 런타임 맵만 **전역
|
|
7
|
+
* 이름 공간**이었다.
|
|
8
|
+
*
|
|
9
|
+
* **충돌은 이론이 아니다.** 레퍼런스 경로가 `instanceId` 로 **레퍼런스 소스 이름**을 쓴다
|
|
10
|
+
* (`sap-ewm-1710`). 벤더 시스템 이름은 테넌트가 **공유하는 어휘**라, 두 테넌트가 같은 시스템에
|
|
11
|
+
* 연결하면 같은 id 가 된다 — 드문 사고가 아니라 예상되는 경우다.
|
|
12
|
+
*
|
|
13
|
+
* 그래서 무엇이 어긋났나:
|
|
14
|
+
* · `startLive` 의 조기 반환이 A 의 런타임을 B 에게 돌려주고 `ingestLive` 는 도메인을 확인하지 않아
|
|
15
|
+
* **B 의 라이브 이벤트가 A 의 커널에 적용**될 수 있었다.
|
|
16
|
+
* · `recovered` 가 겹치면 웜스타트 씨앗이 **남의 스냅샷**이 되어 재고가 섞인다.
|
|
17
|
+
* · `start`/`stop` 이 남의 런타임을 집으므로 B 는 "기동됨" 을 받고도 이후 전부 거부됐다.
|
|
18
|
+
*
|
|
19
|
+
* 이 부류는 **이미 한 번 물었다** — `twin-lifecycle-mutation.ts` 주석이 증언한다: "과거엔
|
|
20
|
+
* `Object.keys(TwinEngine.instances)` 로 전역 반환 → 다른 도메인 트윈이 새던 버그." 그 자리만 고쳤다.
|
|
21
|
+
* 그래서 이번에는 가드를 더 붙이지 않고 **키를 고친다** — 저수준 메서드가 `domainId` 를 받게 되는 것이
|
|
22
|
+
* 요점이다. "누구의 트윈인가" 를 말하지 않고는 만질 수 없다.
|
|
23
|
+
*
|
|
24
|
+
* ── 구분자 규약 ─────────────────────────────────────────────────────────────
|
|
25
|
+
* `${domainId}:${instanceId}` — 이 저장소가 이미 쓰는 모양이다(`structureRevCache`·`measuredCache`).
|
|
26
|
+
* 새 모양을 만들지 않는다. 파싱은 **첫 구분자**에서 자르므로 `instanceId` 에 `:` 가 들어 있어도
|
|
27
|
+
* 안전하다(레퍼런스 소스 이름에 들어갈 수 있다). 대신 `domainId` 에 구분자가 있으면 키가 모호해지므로
|
|
28
|
+
* **그때는 던진다** — 조용히 잘못된 키를 만들면 그게 곧 이 결함의 재발이다.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** 도메인과 인스턴스 id 를 잇는 문자 — 저장소의 기존 캐시 키와 같은 모양. */
|
|
32
|
+
export const RUNTIME_KEY_SEP = ':'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 런타임 레지스트리 키.
|
|
36
|
+
*
|
|
37
|
+
* @throws 도메인 id 가 비었거나 구분자를 담고 있으면(키가 모호해진다). 인스턴스 id 가 비었으면.
|
|
38
|
+
*/
|
|
39
|
+
export function runtimeKey(domainId: string, instanceId: string): string {
|
|
40
|
+
if (!domainId) throw new Error('runtimeKey: domainId is required — a twin runtime always belongs to a tenant')
|
|
41
|
+
if (!instanceId) throw new Error('runtimeKey: instanceId is required')
|
|
42
|
+
if (domainId.includes(RUNTIME_KEY_SEP)) {
|
|
43
|
+
throw new Error(`runtimeKey: domainId must not contain "${RUNTIME_KEY_SEP}" (would make the key ambiguous)`)
|
|
44
|
+
}
|
|
45
|
+
return `${domainId}${RUNTIME_KEY_SEP}${instanceId}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 키를 되돌린다 — 첫 구분자에서 자른다(인스턴스 id 안의 구분자는 보존). */
|
|
49
|
+
export function parseRuntimeKey(key: string): { domainId: string; instanceId: string } {
|
|
50
|
+
const at = key.indexOf(RUNTIME_KEY_SEP)
|
|
51
|
+
if (at <= 0 || at === key.length - 1) throw new Error(`parseRuntimeKey: "${key}" is not a runtime key`)
|
|
52
|
+
return { domainId: key.slice(0, at), instanceId: key.slice(at + 1) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 이 키가 이 테넌트의 것인가 — 레지스트리를 훑을 때 도메인으로 걸러낸다. */
|
|
56
|
+
export function isOfDomain(key: string, domainId: string): boolean {
|
|
57
|
+
return !!domainId && key.startsWith(`${domainId}${RUNTIME_KEY_SEP}`)
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 상태 축 고르기 — **순수**. "요청한 축만 남긴다."
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 있나 (2026-08-05) ────────────────────────────────────────────────────
|
|
5
|
+
* 지도는 구독으로 "바뀌었다" 신호를 받고 상태를 다시 읽는다. 그 신호는 의도적으로 가볍고(`{kind,
|
|
6
|
+
* revision}`) 리프레시도 250ms 로 합쳐진다 — 거기까지는 설계대로다. 문제는 **리프레시가 통째로**
|
|
7
|
+
* 라는 것이다: 실 저널로 재 보니 트윈 하나가 최대 710KB 이고, 같은 공간에 셋이면 한 번에 약 2MB 다.
|
|
8
|
+
*
|
|
9
|
+
* 그런데 지도가 실제로 쓰는 것은 `locations`·`attentions`·`nowTime` **셋뿐**이다(코드 확인). 물품
|
|
10
|
+
* 2,278건·작업·오더를 받아 놓고 쓰지 않는다. 축을 골라 받으면 트윈에 따라 1.7~15배 줄어든다
|
|
11
|
+
* (물품이 많은 트윈은 15배, 신호가 많은 트윈은 2배 — 신호가 큰 쪽은 그것이 다음 과제다).
|
|
12
|
+
*
|
|
13
|
+
* ── 화면 이름을 API 에 넣지 않는다 ──────────────────────────────────────────
|
|
14
|
+
* `twinMapState` 같은 화면 전용 질의를 만들지 않는다. 호출부가 **필요한 축을 말하고** 서버가 자른다 —
|
|
15
|
+
* 새 화면이 생길 때마다 질의가 늘지 않고, 어휘도 화면 이름이 아니라 상태의 축이다(무방언).
|
|
16
|
+
*
|
|
17
|
+
* 모르는 축 이름은 **조용히 버리지 않고 무시하되 그 사실을 돌려준다** — 오타 하나로 화면이 빈
|
|
18
|
+
* 채로 뜨는 것이 가장 나쁘다.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 상태가 들고 있는 축 — 이 목록 밖의 이름은 고를 수 없다. */
|
|
22
|
+
export const STATE_AXES = ['locations', 'items', 'equipment', 'persons', 'assets', 'tasks', 'orders', 'attentions', 'acked'] as const
|
|
23
|
+
export type StateAxis = (typeof STATE_AXES)[number]
|
|
24
|
+
|
|
25
|
+
/** 축과 무관하게 늘 남는 것 — 이것이 없으면 무엇을 받았는지 알 수 없다. */
|
|
26
|
+
const ALWAYS = ['revision', 'nowTime', 'simClockMs', 'corrections'] as const
|
|
27
|
+
|
|
28
|
+
export interface PickedAxes {
|
|
29
|
+
/** 고른 축만 남은 상태. `axes` 가 비었으면 원본 그대로(자르지 않는다). */
|
|
30
|
+
state: any
|
|
31
|
+
/** 상태 축이 아닌 이름 — 호출부가 오타를 알 수 있게 돌려준다. */
|
|
32
|
+
unknown: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 요청한 축만 남긴다.
|
|
37
|
+
*
|
|
38
|
+
* `axes` 가 비어 있으면 **자르지 않는다** — 기본값이 "전부" 여야 기존 호출부가 그대로 동작한다
|
|
39
|
+
* (자르는 쪽이 기본이면 축을 말하지 않은 화면이 조용히 비어 버린다).
|
|
40
|
+
*/
|
|
41
|
+
export function pickAxes(state: any, axes: readonly string[] | undefined | null): PickedAxes {
|
|
42
|
+
if (!state || typeof state !== 'object') return { state, unknown: [] }
|
|
43
|
+
if (!axes?.length) return { state, unknown: [] }
|
|
44
|
+
|
|
45
|
+
const known = new Set<string>(STATE_AXES as readonly string[])
|
|
46
|
+
const wanted = axes.filter(a => known.has(a))
|
|
47
|
+
const unknown = axes.filter(a => !known.has(a))
|
|
48
|
+
/* 아는 축이 하나도 없으면 자르지 않는다 — 전부 오타인 경우에 빈 화면을 만들지 않는다. */
|
|
49
|
+
if (!wanted.length) return { state, unknown }
|
|
50
|
+
|
|
51
|
+
const out: any = {}
|
|
52
|
+
for (const k of ALWAYS) if (k in state) out[k] = state[k]
|
|
53
|
+
for (const a of wanted) if (a in state) out[a] = state[a]
|
|
54
|
+
return { state: out, unknown }
|
|
55
|
+
}
|