@things-factory/headless-twin 10.0.17 → 10.0.19
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 +12 -85
- package/dist-server/engine/canonical-ingest.js +92 -26
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/energy-topology.js +3 -3
- package/dist-server/engine/energy-topology.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +91 -3
- package/dist-server/engine/kpi-fold.js +149 -25
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +99 -11
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/local-declarations.js +6 -5
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/model-vocabulary.js +2 -2
- package/dist-server/engine/model-vocabulary.js.map +1 -1
- package/dist-server/engine/oee-accumulator.d.ts +2 -1
- package/dist-server/engine/oee-accumulator.js +3 -2
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +72 -2
- package/dist-server/engine/twin-engine.js +150 -4
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/routes.js +13 -2
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
- package/dist-server/service/actuation/command-dispatcher.js +92 -0
- package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
- package/dist-server/service/actuation/command-store.d.ts +3 -0
- package/dist-server/service/actuation/command-store.js +56 -0
- package/dist-server/service/actuation/command-store.js.map +1 -0
- package/dist-server/service/actuation/index.d.ts +6 -0
- package/dist-server/service/actuation/index.js +11 -0
- package/dist-server/service/actuation/index.js.map +1 -0
- package/dist-server/service/actuation/twin-command.d.ts +30 -0
- package/dist-server/service/actuation/twin-command.js +110 -0
- package/dist-server/service/actuation/twin-command.js.map +1 -0
- package/dist-server/service/index.d.ts +2 -2
- package/dist-server/service/index.js +22 -16
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/hook-contract.d.ts +64 -21
- package/dist-server/service/reference/hook-contract.js +134 -22
- package/dist-server/service/reference/hook-contract.js.map +1 -1
- package/dist-server/service/reference/hook-store.d.ts +3 -0
- package/dist-server/service/reference/hook-store.js +28 -0
- package/dist-server/service/reference/hook-store.js.map +1 -0
- package/dist-server/service/reference/reference-adapter.d.ts +65 -1
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-hook.d.ts +42 -6
- package/dist-server/service/reference/reference-hook.js +185 -27
- package/dist-server/service/reference/reference-hook.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +1 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +11 -2
- package/dist-server/service/reference/reference-master.js +1 -1
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +2 -2
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
- package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
- package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
- package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
- package/dist-server/service/twin-backfill/index.d.ts +2 -0
- package/dist-server/service/twin-backfill/index.js +6 -0
- package/dist-server/service/twin-backfill/index.js.map +1 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/name-index.js +1 -1
- package/dist-server/service/twin-model/name-index.js.map +1 -1
- package/dist-server/service/twin-model/project-structure.js +4 -3
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
- package/dist-server/service/twin-model/twin-equipment.js +2 -2
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +4 -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 +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.js +1 -1
- package/dist-shared/entity-delta.js.map +1 -1
- package/package.json +7 -6
- package/server/engine/canonical-ingest.ts +72 -84
- package/server/engine/energy-topology.ts +1 -1
- package/server/engine/kpi-fold.ts +219 -36
- package/server/engine/kpi-query.ts +97 -4
- package/server/engine/local-declarations.ts +2 -1
- package/server/engine/model-vocabulary.ts +1 -1
- package/server/engine/oee-accumulator.ts +4 -2
- package/server/engine/twin-engine.ts +163 -5
- package/server/routes.ts +18 -2
- package/server/service/actuation/command-dispatcher.ts +130 -0
- package/server/service/actuation/command-store.ts +62 -0
- package/server/service/actuation/index.ts +8 -0
- package/server/service/actuation/twin-command.ts +105 -0
- package/server/service/index.ts +6 -0
- package/server/service/reference/hook-contract.ts +99 -21
- package/server/service/reference/hook-store.ts +28 -0
- package/server/service/reference/reference-adapter.ts +67 -2
- package/server/service/reference/reference-hook.ts +249 -32
- package/server/service/reference/reference-live.ts +8 -2
- package/server/service/reference/reference-master.ts +12 -3
- package/server/service/reference/reference-resolver.ts +1 -1
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
- package/server/service/twin-backfill/backfill-shape.ts +86 -0
- package/server/service/twin-backfill/index.ts +3 -0
- package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
- package/server/service/twin-model/name-index.ts +1 -1
- package/server/service/twin-model/project-structure.ts +3 -2
- package/server/service/twin-model/twin-equipment.ts +14 -2
- package/server/service/twin-model/twin-model-item-query.ts +1 -1
- package/server/service/twin-model/twin-model-query.ts +2 -2
- package/server/service/twin-model/twin-model-tree-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +1 -1
- package/shared/entity-delta.ts +1 -1
- package/test/actuation-dispatch.test.ts +196 -0
- package/test/axis-read.test.ts +4 -3
- package/test/backfill-shape.test.ts +89 -0
- package/test/broadcast-cost-baseline.test.ts +2 -1
- package/test/canonical-ingest-vocabularies.test.ts +82 -3
- package/test/capability-mapping.test.ts +1 -1
- package/test/contract-layer-guard.test.ts +48 -0
- package/test/declaration-reaches-model.test.ts +2 -0
- package/test/equipment-identity-reaches.test.ts +57 -0
- package/test/fact-scope-wiring.test.ts +96 -0
- package/test/generated-not-counted.test.ts +99 -0
- package/test/hook-sequence.test.ts +333 -0
- package/test/ingest-bench.test.ts +2 -1
- package/test/ingest-wiring-guard.test.ts +197 -0
- package/test/kpi-fold.test.ts +81 -0
- package/test/live-mirror-parity.test.ts +6 -3
- package/test/local-declarations.test.ts +3 -2
- package/test/master-to-twin.test.ts +2 -2
- package/test/oee-accumulator.test.ts +18 -3
- package/test/projection-reaches-screen.test.ts +5 -0
- package/test/projection-reads-declared.test.ts +109 -0
- package/test/property-effects.test.ts +7 -3
- package/test/reference-hook.test.ts +114 -23
- package/test/scale-twin-bench.test.ts +2 -1
- package/test/vocabulary-guard.test.ts +1 -1
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -19,7 +19,8 @@ import { mergeStreams } from '../service/twin-journal/event-type-split-shape.js'
|
|
|
19
19
|
import { hydratedDate } from './read-time.js'
|
|
20
20
|
import { Between, In } from 'typeorm'
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import { type TariffDeclaration } from '@operato/twin-kernel'
|
|
23
|
+
import { hierarchyOf, levelOfLocationType, electricalUpstreamOf, EMS_LOCATION_TYPES, EMS_PROPERTY } from '@operato/ops-contract'
|
|
23
24
|
|
|
24
25
|
import { getReadRepository, getRepository } from '@things-factory/shell'
|
|
25
26
|
|
|
@@ -51,7 +52,7 @@ import { MAX_BASELINE_PERIODS, baselineWindows, summarizeBaseline, type Baseline
|
|
|
51
52
|
* 나눈 값이라, 두 사실이 한 폴드에 들어와야 창이 어긋날 수 없다. 표본(`energy.measured`)은 읽지 않는다 —
|
|
52
53
|
* 저널에 없고(§4.1 인제스트 계약) 여기서 필요한 것은 마감된 구간뿐이다.
|
|
53
54
|
*/
|
|
54
|
-
const BUSINESS_EVENTS = ['task.status', 'order.status', 'energy.demand.window', 'energy.generation.period', 'energy.usage.period', 'energy.bill']
|
|
55
|
+
const BUSINESS_EVENTS = ['task.status', 'order.status', 'energy.demand.window', 'energy.generation.period', 'energy.usage.period', 'energy.bill', 'energy.generation.price']
|
|
55
56
|
|
|
56
57
|
/** 한 번에 계산할 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */
|
|
57
58
|
const MAX_EVENTS = 20000
|
|
@@ -63,7 +64,27 @@ const MAX_EVENTS = 20000
|
|
|
63
64
|
* 메모리가 그만큼 든다. 셋(종류) × 둘(트윈)까지는 최악에 12만 행이고, 그 이상은 예전 한 질의로 간다 —
|
|
64
65
|
* 느리지만 답은 같다.
|
|
65
66
|
*/
|
|
66
|
-
|
|
67
|
+
/*
|
|
68
|
+
* ── 나눠 물을 수 있는 갈래의 최대 (2026-08-30 실측) ──────────────────────────
|
|
69
|
+
*
|
|
70
|
+
* 6 이었다. 그런데 **한 현장에 트윈이 여럿인 것이 이 제품의 정상**이다(창고·공정·야드가 한 현실을
|
|
71
|
+
* 각각 본다). 트윈 3개 × 종류 6가지 = 18 이라 조건에 안 걸렸고, 한 질의로 떨어졌다.
|
|
72
|
+
*
|
|
73
|
+
* 같은 창·같은 행으로 두 모양을 쟀다(hatio-us, order.status 345만 건이 있는 하루):
|
|
74
|
+
*
|
|
75
|
+
* 한 질의(종류 IN + 시각순) ix_twin_event_1 로 그 날 전체를 걷는다 25.987초 · 20,000건(잘림)
|
|
76
|
+
* 갈래 하나(종류 정확일치) ix_twin_event_7 이 순서까지 준다 0.286초
|
|
77
|
+
*
|
|
78
|
+
* 91배다. 그리고 25초를 기다린 끝에 나오는 값이 **잘린 값**이다 — 사용자는 그 사실을 25초 뒤에 본다.
|
|
79
|
+
*
|
|
80
|
+
* ── 늘리는 대가 ────────────────────────────────────────────────────────────
|
|
81
|
+
* 갈래마다 상한까지 받아야 합칠 때 정확하므로(§`mergeStreams`), 갈래가 많으면 그만큼 든다.
|
|
82
|
+
* 최악은 `갈래 수 × MAX_EVENTS` 행이다. 다만 그 최악은 **모든 갈래가 창을 가득 채울 때**이고,
|
|
83
|
+
* 그때는 어차피 결과가 잘린다(그 사실은 `capped` 로 나간다).
|
|
84
|
+
*
|
|
85
|
+
* 32 는 트윈 4개 × 종류 8가지를 덮는다. 어휘가 더 늘면 이 수를 다시 재고 정한다.
|
|
86
|
+
*/
|
|
87
|
+
const KPI_MAX_SPLIT = 32
|
|
67
88
|
|
|
68
89
|
export interface TwinKpiInput {
|
|
69
90
|
domainId: string
|
|
@@ -480,6 +501,59 @@ function latestBillOf(domainId: string, instanceIds: string[]): { to: string; bi
|
|
|
480
501
|
return best
|
|
481
502
|
}
|
|
482
503
|
|
|
504
|
+
/** 이 트윈들이 이어받고 있는 **발전 단가** — 창 안에 단가 사실이 없을 때 쓴다. */
|
|
505
|
+
function latestGenerationPriceOf(
|
|
506
|
+
domainId: string,
|
|
507
|
+
instanceIds: string[]
|
|
508
|
+
): { from: string; to: string; unitPrice: number; currency: string } | undefined {
|
|
509
|
+
let best: { from: string; to: string; unitPrice: number; currency: string } | undefined
|
|
510
|
+
for (const instanceId of instanceIds) {
|
|
511
|
+
const p = (TwinEngine.snapshot(domainId, instanceId) as any)?.energy?.generationPrice
|
|
512
|
+
if (!p?.from || !Number.isFinite(Number(p.unitPrice)) || !p.currency) continue
|
|
513
|
+
if (!best || Date.parse(p.from) > Date.parse(best.from)) {
|
|
514
|
+
best = { from: String(p.from), to: String(p.to), unitPrice: Number(p.unitPrice), currency: String(p.currency) }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return best
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 이 트윈들의 **발전 정격** — 이용률의 분모.
|
|
522
|
+
*
|
|
523
|
+
* 커널이 고른다(설비 합 → 자리 합계, 교류 → 직류). 여러 트윈이면 합한다 — 한 현장의 발전은
|
|
524
|
+
* 트윈마다 나뉘어 있어도 같은 현장의 발전이다. 근거가 갈리면 약한 쪽(직류·자리)으로 적는다.
|
|
525
|
+
*/
|
|
526
|
+
function generationRatedOf(
|
|
527
|
+
domainId: string,
|
|
528
|
+
instanceIds: string[]
|
|
529
|
+
): { kW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location' } | undefined {
|
|
530
|
+
let kW = 0
|
|
531
|
+
let basis: 'ac' | 'dc' = 'ac'
|
|
532
|
+
let from: 'equipment' | 'location' = 'equipment'
|
|
533
|
+
for (const instanceId of instanceIds) {
|
|
534
|
+
/* 스냅샷에서 읽는다 — 커널이 고른 값이다. 여기서 모델을 다시 뒤지면 고르는 규칙이 두 벌이 된다. */
|
|
535
|
+
const r = (TwinEngine.snapshot(domainId, instanceId) as any)?.energy?.generationRated
|
|
536
|
+
if (!r || !(Number(r.kW) > 0)) continue
|
|
537
|
+
kW += Number(r.kW)
|
|
538
|
+
if (r.basis === 'dc') basis = 'dc'
|
|
539
|
+
if (r.from === 'location') from = 'location'
|
|
540
|
+
}
|
|
541
|
+
return kW > 0 ? { kW, basis, from } : undefined
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 이 트윈들이 이어받고 있는 **요금 기준** — 이 주기에 적용되는 값(청구서보다 먼저다). */
|
|
545
|
+
function latestTariffBasisOf(domainId: string, instanceIds: string[]): { from: string; billingDemandKW?: number } | undefined {
|
|
546
|
+
let best: { from: string; billingDemandKW?: number } | undefined
|
|
547
|
+
for (const instanceId of instanceIds) {
|
|
548
|
+
const b = (TwinEngine.snapshot(domainId, instanceId) as any)?.energy?.tariffBasis
|
|
549
|
+
if (!b?.from) continue
|
|
550
|
+
if (!best || Date.parse(b.from) > Date.parse(best.from)) {
|
|
551
|
+
best = { from: String(b.from), ...(Number.isFinite(Number(b.billingDemandKW)) ? { billingDemandKW: Number(b.billingDemandKW) } : {}) }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return best
|
|
555
|
+
}
|
|
556
|
+
|
|
483
557
|
async function energyTopologyOf(
|
|
484
558
|
domainId: string,
|
|
485
559
|
instanceIds: string[],
|
|
@@ -810,7 +884,19 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
810
884
|
: 'no operational events in the journal yet — there is nothing to measure (this is NOT "zero throughput").'
|
|
811
885
|
}
|
|
812
886
|
}
|
|
813
|
-
|
|
887
|
+
/*
|
|
888
|
+
* ── **미래를 성과 구간으로 삼지 않는다** (2026-08-30) ──────────────────────
|
|
889
|
+
*
|
|
890
|
+
* 마지막 기록이 미래일 수 있다. 한 원본이 **끝나지 않은 이번 달**의 요금 정보를 청구서로 보냈고,
|
|
891
|
+
* 청구서의 사건 시각은 기간의 끝이라 그 사실이 **내일** 일어난 것으로 적혔다. 그러자 구간이
|
|
892
|
+
* `내일 00:00 → 모레 00:00` 이 되어 그 현장의 화면에서 **어제 발전량이 사라졌다.** 오류는 나지
|
|
893
|
+
* 않았고 화면만 비어 있었다.
|
|
894
|
+
*
|
|
895
|
+
* 커널이 이제 그런 사실을 받지 않는다. 그래도 여기서 막는다 — **이미 적힌 것은 사라지지 않고**,
|
|
896
|
+
* 저널은 지우지 않기 때문이다. 그리고 성과는 지나간 일이다. 아직 오지 않은 구간의 성과라는 것은
|
|
897
|
+
* 없다.
|
|
898
|
+
*/
|
|
899
|
+
toMs = Math.min(Date.parse(latest), Date.now())
|
|
814
900
|
basis = 'latest-event'
|
|
815
901
|
}
|
|
816
902
|
|
|
@@ -842,6 +928,10 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
842
928
|
* **커널 상태에 이어져 있는 마지막 청구서**를 함께 넘긴다(§`EnergyState.lastBill`).
|
|
843
929
|
*/
|
|
844
930
|
const lastBill = latestBillOf(input.domainId, instanceIds)
|
|
931
|
+
const tariffBasis = latestTariffBasisOf(input.domainId, instanceIds)
|
|
932
|
+
/* 이용률의 분모 — 커널이 모델의 선언에서 고른다(설비 합 → 자리 합계, 교류 → 직류). */
|
|
933
|
+
const generationRated = generationRatedOf(input.domainId, instanceIds)
|
|
934
|
+
const generationPrice = latestGenerationPriceOf(input.domainId, instanceIds)
|
|
845
935
|
const kpi = foldKpi(events, { fromMs, toMs }, {
|
|
846
936
|
groupBy: input.groupBy,
|
|
847
937
|
locationArea: areaMap.locationArea,
|
|
@@ -849,6 +939,9 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
|
|
|
849
939
|
meterFeeder: feederMap.meterFeeder,
|
|
850
940
|
...(tariffFound.tariff ? { tariff: tariffFound.tariff } : {}),
|
|
851
941
|
...(lastBill ? { lastBill } : {}),
|
|
942
|
+
...(tariffBasis ? { tariffBasis } : {}),
|
|
943
|
+
...(generationRated ? { generationRated } : {}),
|
|
944
|
+
...(generationPrice ? { generationPrice } : {}),
|
|
852
945
|
...shiftCtx,
|
|
853
946
|
groupLimit: input.groupLimit
|
|
854
947
|
})
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
* **속성 어휘는 사용자가 정의하지 않는다** — 커널이 정한 속성에 값을 넣는 문이다(그래서 이름도
|
|
38
38
|
* 「사용자 정의 속성」이 아니다).
|
|
39
39
|
*/
|
|
40
|
-
import { ATTENTION_PROPERTY,
|
|
40
|
+
import { ATTENTION_PROPERTY, OPERATION_PROPERTY } from '@operato/twin-kernel'
|
|
41
|
+
import { EMS_PROPERTY_SPEC, OP_PARAM } from '@operato/ops-contract'
|
|
41
42
|
|
|
42
43
|
import { EQUIPMENT_PROPERTY, SPEED_TO_MPS } from './travel-estimator.ts'
|
|
43
44
|
import { declarationTargetsOf } from './property-effects.ts'
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* 저장은 주는 대로 한다(새 쓰기는 이미 새 어휘다). 읽기가 정규화되므로, 저장된 옛 모델는 **다음에
|
|
26
26
|
* 저장될 때 자연히 새 어휘로 옮겨간다** — 마이그레이션을 따로 실행하지 않는다.
|
|
27
27
|
*/
|
|
28
|
-
import { readBoardLocations, readBoardEquipment } from '@operato/
|
|
28
|
+
import { readBoardLocations, readBoardEquipment } from '@operato/ops-contract'
|
|
29
29
|
|
|
30
30
|
/** 흡수하고 **내보내지 않는** 옛 키 — 남겨 두면 소비처가 두 어휘를 다시 보게 된다. */
|
|
31
31
|
const RETIRED_KEYS = ['nodes', 'movers', 'equipmentList'] as const // vocabulary-guard: allow — 옛 어휘를 흡수하는 것이 이 코드의 일이다(세대 이름을 적어야 흡수할 수 있다)
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* - quality.output 이 없으면(예: WMS/YMS) quality=1(품질 개념 없음).
|
|
11
11
|
* 시간축 = Date.parse(eventTime)(ms) — 구간 차분은 sim/실시간 무관하게 정확(고정 오프셋 상쇄).
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
import
|
|
13
|
+
/* OEE 공식은 계약에 있다 — 소비자가 셋이라(시뮬·미러·MES) 커널에 두면 밖에서 다시 쓰게 된다. */
|
|
14
|
+
import { computeOee } from '@operato/ops-contract'
|
|
15
|
+
import type { OeeCounters } from '@operato/ops-contract'
|
|
16
|
+
import type { CanonicalEnvelope, OeeMetrics } from '@operato/ops-contract'
|
|
15
17
|
|
|
16
18
|
interface Acc {
|
|
17
19
|
status?: string
|
|
@@ -14,7 +14,8 @@ import { pubsub, getRepository, Domain } from '@things-factory/shell'
|
|
|
14
14
|
import { And, IsNull, LessThanOrEqual, MoreThan } from 'typeorm'
|
|
15
15
|
import { cacheService } from '@things-factory/cache-service'
|
|
16
16
|
|
|
17
|
-
import type { ReducerCheckpoint
|
|
17
|
+
import type { ReducerCheckpoint } from '@operato/twin-kernel'
|
|
18
|
+
import type { VocabularyElement } from '@operato/ops-contract'
|
|
18
19
|
import type { OeeCheckpoint } from './oee-accumulator.js'
|
|
19
20
|
import { TwinEvent } from '../service/twin-event/twin-event.js'
|
|
20
21
|
import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
|
|
@@ -76,11 +77,13 @@ import {
|
|
|
76
77
|
type IngestWindow
|
|
77
78
|
} from './ingest-health.js'
|
|
78
79
|
import { FactDeduper } from './ingest-dedupe.js'
|
|
79
|
-
import { EMS_PROPERTY } from '@operato/
|
|
80
|
-
import type {
|
|
80
|
+
import { EMS_PROPERTY } from '@operato/ops-contract'
|
|
81
|
+
import type { SubscriptionMessage, TwinRuntime as TwinRuntimeType } from '@operato/twin-kernel'
|
|
82
|
+
import type { TwinKernel, TwinModelDef, StructureShift, CanonicalEnvelope } from '@operato/ops-contract'
|
|
81
83
|
|
|
82
84
|
/* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
|
|
83
|
-
const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments
|
|
85
|
+
const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments } = require('@operato/twin-kernel')
|
|
86
|
+
const { readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/ops-contract')
|
|
84
87
|
|
|
85
88
|
const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel }
|
|
86
89
|
|
|
@@ -1609,6 +1612,8 @@ export class TwinEngine {
|
|
|
1609
1612
|
|
|
1610
1613
|
const Kernel = kernelFor(kind)
|
|
1611
1614
|
const kernel: TwinKernel = new Kernel(domainId, undefined, this.productionSpecOf(model))
|
|
1615
|
+
/* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */
|
|
1616
|
+
;(kernel as any).scopeId = id
|
|
1612
1617
|
kernel.loadTwinModel(model) // 구조만. 상태는 아래 웜스타트가 주입한다.
|
|
1613
1618
|
this.applyOperations(kernel, model, id) // 시간·수율 명세(있으면) — 없으면 커널 기본값
|
|
1614
1619
|
/* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
|
|
@@ -1814,6 +1819,8 @@ export class TwinEngine {
|
|
|
1814
1819
|
if (this.instances[key]) return this.instances[key]
|
|
1815
1820
|
const Kernel = kernelFor(kind)
|
|
1816
1821
|
const kernel: any = new Kernel(domainId, undefined, this.productionSpecOf(model))
|
|
1822
|
+
/* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */
|
|
1823
|
+
;(kernel as any).scopeId = id
|
|
1817
1824
|
kernel.loadTwinModel(model)
|
|
1818
1825
|
/* **세우는 쪽이 아는 사실은 세울 때 말한다.** 예전에는 첫 이벤트가 도착해야 커널이 스스로를
|
|
1819
1826
|
관측 구동으로 여겼고, 그래서 아직 아무것도 못 받은 미러는 시뮬레이션 취급을 받았다. */
|
|
@@ -2416,9 +2423,24 @@ export class TwinEngine {
|
|
|
2416
2423
|
|
|
2417
2424
|
if (carried.length) jobs.push(this.persistCarried(inst.domainId, inst.id, carried))
|
|
2418
2425
|
if (plain.length && inst.revision != null) {
|
|
2426
|
+
/*
|
|
2427
|
+
* ── 마감된 구간 사실은 한 번만 적는다 (2026-08-30) ───────────────────────
|
|
2428
|
+
*
|
|
2429
|
+
* 이미 있는 것을 걸러낸 뒤에 번호를 매긴다. 걸러내기 전에 매기면 **쓰지 않은 번호가 비고**,
|
|
2430
|
+
* 그 구멍이 저널의 리비전 연속성을 끊는다.
|
|
2431
|
+
*/
|
|
2432
|
+
/*
|
|
2433
|
+
* 번호는 **먼저 잡는다**(동기). 걸러낸 뒤에 잡으면 두 flush 가 겹칠 때 같은 번호를 두 번 쓴다.
|
|
2434
|
+
* 걸러진 만큼 번호가 비지만, 그 구멍은 해롭지 않다 — 리비전은 같은 시각을 가리는 데만 쓰고
|
|
2435
|
+
* 이어짐을 요구하지 않는다(저널 머리는 최대값이다).
|
|
2436
|
+
*/
|
|
2419
2437
|
const start = inst.revision
|
|
2420
2438
|
inst.revision = start + plain.length
|
|
2421
|
-
jobs.push(
|
|
2439
|
+
jobs.push(
|
|
2440
|
+
this.dropAlreadyWritten(inst.domainId, inst.id, plain).then(fresh =>
|
|
2441
|
+
fresh.length ? this.persistBatch(inst.domainId, inst.id, fresh, start) : undefined
|
|
2442
|
+
)
|
|
2443
|
+
)
|
|
2422
2444
|
}
|
|
2423
2445
|
if (!jobs.length) return Promise.resolve()
|
|
2424
2446
|
/*
|
|
@@ -2445,6 +2467,76 @@ export class TwinEngine {
|
|
|
2445
2467
|
* 예전에는 이 경로가 **델타마다 한 행씩** 저장했다(그리고 행마다 구조 리비전을 물었다). 규모에서 그것이
|
|
2446
2468
|
* 호스트를 먹었다(§7.1 실측). 여기서 구조 리비전은 **한 번만** 묻는다.
|
|
2447
2469
|
*/
|
|
2470
|
+
/**
|
|
2471
|
+
* **마감된 구간 사실은 한 번만 적는다** — 같은 사실이 두 번 오면 한 사실이다 (2026-08-30).
|
|
2472
|
+
*
|
|
2473
|
+
* ── 왜 쓰는 자리에서 막나 ──────────────────────────────────────────────────
|
|
2474
|
+
* 커널이 그 사실들의 정체성을 내용에서 만든다(종류·대상·시작·끝). 그런데 저널의 기본키는 새로
|
|
2475
|
+
* 만드는 uuid 라, **id 가 글자 하나까지 같아도 행이 따로 쌓였다.**
|
|
2476
|
+
*
|
|
2477
|
+
* 실측(2026-08-30): 발전 단가가 행 7개·서로 다른 id 4개였다. 8월 27일 단가가 세 벌 있었고,
|
|
2478
|
+
* 그 구간의 수익이 세 번 세어진다.
|
|
2479
|
+
*
|
|
2480
|
+
* 커넥터가 커서로 막는 것이 첫 방어선이고 그쪽도 고쳤다. 그런데 **커넥터가 한 번 실수하면 저널이
|
|
2481
|
+
* 부풀고 그 위의 모든 합이 틀린다.** 정체성은 커널이 선언한 사실이므로, 어느 문으로 들어오든
|
|
2482
|
+
* 지켜져야 한다 — 채우기 경로에만 있으면 그 보증은 「어느 문으로 왔느냐」에 걸린다.
|
|
2483
|
+
*
|
|
2484
|
+
* ── 값이 싼 이유 ───────────────────────────────────────────────────────────
|
|
2485
|
+
* 마감된 구간 사실만 본다(시점의 관측은 그대로 지난다 — 반복이 뜻을 가진다). 그것들은 드물어서
|
|
2486
|
+
* (한 시간에 한 줄) 한 묶음에 몇 개뿐이고, 그 구간의 그 종류만 색인으로 읽는다.
|
|
2487
|
+
*/
|
|
2488
|
+
private static readonly PERIOD_FACT_TYPES = new Set([
|
|
2489
|
+
'energy.usage.period',
|
|
2490
|
+
'energy.generation.period',
|
|
2491
|
+
'energy.generation.price',
|
|
2492
|
+
'energy.tariff.basis',
|
|
2493
|
+
'energy.bill'
|
|
2494
|
+
])
|
|
2495
|
+
|
|
2496
|
+
static async dropAlreadyWritten(domainId: string, instanceId: string, envelopes: any[]): Promise<any[]> {
|
|
2497
|
+
const periodFacts = envelopes.filter(e => this.PERIOD_FACT_TYPES.has(String(e?.eventType)) && typeof e?.eventId === 'string')
|
|
2498
|
+
if (!periodFacts.length) return envelopes
|
|
2499
|
+
|
|
2500
|
+
const times = periodFacts.map(e => Date.parse(String(e.eventTime))).filter(n => Number.isFinite(n))
|
|
2501
|
+
if (!times.length) return envelopes
|
|
2502
|
+
const types = [...new Set(periodFacts.map(e => String(e.eventType)))]
|
|
2503
|
+
|
|
2504
|
+
const existing = await getRepository(TwinEvent)
|
|
2505
|
+
.createQueryBuilder('e')
|
|
2506
|
+
.select(['e.payload AS "payload"'])
|
|
2507
|
+
.where('e.domain = :domainId', { domainId })
|
|
2508
|
+
.andWhere('e.instanceId = :instanceId', { instanceId })
|
|
2509
|
+
.andWhere('e.eventType IN (:...types)', { types })
|
|
2510
|
+
.andWhere('e.eventTime BETWEEN :fromAt AND :toAt', { fromAt: new Date(Math.min(...times)), toAt: new Date(Math.max(...times)) })
|
|
2511
|
+
.getRawMany<{ payload: unknown }>()
|
|
2512
|
+
.catch(() => [] as { payload: unknown }[])
|
|
2513
|
+
|
|
2514
|
+
const seen = new Set<string>()
|
|
2515
|
+
for (const row of existing) {
|
|
2516
|
+
let p: any = row.payload
|
|
2517
|
+
if (typeof p === 'string') {
|
|
2518
|
+
try {
|
|
2519
|
+
p = JSON.parse(p)
|
|
2520
|
+
} catch {
|
|
2521
|
+
p = undefined
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
if (typeof p?.eventId === 'string' && p.eventId) seen.add(p.eventId)
|
|
2525
|
+
}
|
|
2526
|
+
/* 한 묶음 안에 같은 것이 둘 있어도 하나만 적는다. */
|
|
2527
|
+
const kept: any[] = []
|
|
2528
|
+
for (const e of envelopes) {
|
|
2529
|
+
if (!this.PERIOD_FACT_TYPES.has(String(e?.eventType)) || typeof e?.eventId !== 'string') {
|
|
2530
|
+
kept.push(e)
|
|
2531
|
+
continue
|
|
2532
|
+
}
|
|
2533
|
+
if (seen.has(e.eventId)) continue
|
|
2534
|
+
seen.add(e.eventId)
|
|
2535
|
+
kept.push(e)
|
|
2536
|
+
}
|
|
2537
|
+
return kept
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2448
2540
|
static async persistCarried(domainId: string, instanceId: string, items: { event: any; revision: number }[]): Promise<void> {
|
|
2449
2541
|
const repo = getRepository(TwinEvent)
|
|
2450
2542
|
const structureRev = await this.structureRevOf(domainId, instanceId)
|
|
@@ -2580,6 +2672,23 @@ export class TwinEngine {
|
|
|
2580
2672
|
}
|
|
2581
2673
|
|
|
2582
2674
|
/**
|
|
2675
|
+
* ── **트윈은 설비의 마스터가 아니다** (사용자 결정 2026-08-30) ─────────────
|
|
2676
|
+
*
|
|
2677
|
+
* 인원·설비·자산의 마스터는 `@things-factory/ops-master` 다. 트윈은 그것을 **당겨 와** 모델에
|
|
2678
|
+
* 반영하고, 여기 남는 것은 그 결과의 캐시다.
|
|
2679
|
+
*
|
|
2680
|
+
* 그래서 이 함수가 하는 일에 선이 있다.
|
|
2681
|
+
*
|
|
2682
|
+
* 트윈 안에서 사람이 더한 설비 가정·시뮬레이션의 사실이다 — 트윈이 든다 (지금 이 함수)
|
|
2683
|
+
* 현장에 실제로 있는 설비 마스터의 사실이다 — 트윈이 만들지 않는다
|
|
2684
|
+
*
|
|
2685
|
+
* 이 함수는 **트윈 제어 명령**(`resource.add`) 뒤에만 불린다. 관측에서 설비가 생기는 경로가 아니다.
|
|
2686
|
+
* 관측으로 모르는 설비가 들어오면 그것은 **마스터에 없는 것**이고, 트윈이 조용히 만들어 주면 두
|
|
2687
|
+
* 곳이 서로 다른 설비 목록을 갖게 된다. 그때는 만들지 말고 「모르는 설비가 왔다」로 세어야 한다.
|
|
2688
|
+
*
|
|
2689
|
+
* 당겨 오는 길이 붙으면 이 주석의 위쪽 절반만 남는다.
|
|
2690
|
+
*
|
|
2691
|
+
* ── 원래 설명 ─────────────────────────────────────────────────────────────
|
|
2583
2692
|
* 라이브 구조 변이(resource.add 등)를 저장된 model 에 반영 — 런타임 커널의 무버를 registry model.equipment 에 동기.
|
|
2584
2693
|
* 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 model 는 원본이라 프로비저닝 편집기·재기동(loadTwinModel)이
|
|
2585
2694
|
* 추가분을 잃는다. 기존 model.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation).
|
|
@@ -4372,6 +4481,55 @@ export class TwinEngine {
|
|
|
4372
4481
|
* `offered` 는 **제시된 레코드 수**다(거부 여부 무관). 통과율을 서로 다른 두 계수기에서 나눠 계산하면
|
|
4373
4482
|
* 분모와 분자가 다른 것을 세게 되므로, 한자리에서 본 수를 그대로 넘긴다.
|
|
4374
4483
|
*/
|
|
4484
|
+
/**
|
|
4485
|
+
* 이 트윈의 저널이 지금 몇 번까지 적혔나 — **접근자로 낸다.**
|
|
4486
|
+
*
|
|
4487
|
+
* 레지스트리를 밖에서 직접 색인하지 않게 하는 것이 이 저장소의 규율이다(§`engine-registry-access`).
|
|
4488
|
+
* 지난 기록을 채우는 길이 다음 번호를 알아야 해서 열었다.
|
|
4489
|
+
*
|
|
4490
|
+
* 트윈이 도는 중이 아니면 0 이다 — 그때는 저널만 있고 이어 붙일 번호를 커널이 들고 있지 않다.
|
|
4491
|
+
*/
|
|
4492
|
+
/**
|
|
4493
|
+
* 마감된 구간 사실의 **이름을 지을 때 쓸 범위와 선언** — 유입 문에 넘긴다.
|
|
4494
|
+
*
|
|
4495
|
+
* ── 왜 호스트가 주나 (2026-08-30) ────────────────────────────────────────
|
|
4496
|
+
* 커널은 `new Kernel(domainId, …)` 으로 서므로 **자기가 어느 트윈인지 모른다.** 그런데 설비 번호는
|
|
4497
|
+
* 트윈 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이
|
|
4498
|
+
* 된다. 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 여러 현장을 함께 계산하는 성과 화면에서
|
|
4499
|
+
* 한쪽 발전량이 사라진다.
|
|
4500
|
+
*
|
|
4501
|
+
* 선언된 정체성은 **커널이 답한다**(§`FlowEngine.equipmentIdentity`) — 판정을 두 곳에 두지 않는다.
|
|
4502
|
+
* 트윈이 돌고 있지 않으면 선언을 물을 곳이 없으므로 범위만 넘긴다(그때도 겹치지는 않는다).
|
|
4503
|
+
*/
|
|
4504
|
+
static factScope(
|
|
4505
|
+
domainId: string,
|
|
4506
|
+
instanceId: string
|
|
4507
|
+
): { scopeId: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined } {
|
|
4508
|
+
const inst = this.instances[runtimeKey(domainId, instanceId)]
|
|
4509
|
+
const kernel: any = inst?.kernel
|
|
4510
|
+
if (typeof kernel?.equipmentIdentity !== 'function') return { scopeId: instanceId }
|
|
4511
|
+
return {
|
|
4512
|
+
scopeId: instanceId,
|
|
4513
|
+
identityOf: (kind, localId) => (kind === 'equipment' ? kernel.equipmentIdentity(localId) : undefined)
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
static journalHead(domainId: string, instanceId: string): number {
|
|
4518
|
+
const inst = this.instances[runtimeKey(domainId, instanceId)]
|
|
4519
|
+
return Number(inst?.revision ?? 0)
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4522
|
+
/**
|
|
4523
|
+
* 저널에 그만큼 적었으니 번호를 밀어 준다.
|
|
4524
|
+
*
|
|
4525
|
+
* 밀어 주지 않으면 다음에 라이브가 **같은 번호를 다시 쓴다** — 같은 시각의 두 사실을 가릴 수 없게 된다.
|
|
4526
|
+
*/
|
|
4527
|
+
static advanceJournalHead(domainId: string, instanceId: string, by: number): void {
|
|
4528
|
+
const inst = this.instances[runtimeKey(domainId, instanceId)]
|
|
4529
|
+
if (!inst || !(by > 0)) return
|
|
4530
|
+
inst.revision = Number(inst.revision ?? 0) + by
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4375
4533
|
static recordIngestResult(
|
|
4376
4534
|
domainId: string,
|
|
4377
4535
|
instanceId: string,
|
package/server/routes.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { ingestCanonicalRecords } from './engine/canonical-ingest.js'
|
|
19
19
|
import { TwinEngine } from './engine/twin-engine.js'
|
|
20
20
|
import { handleHook } from './service/reference/reference-hook.js'
|
|
21
|
+
import { databaseStore } from './service/reference/hook-store.js'
|
|
21
22
|
|
|
22
23
|
process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: any) => {
|
|
23
24
|
/*
|
|
@@ -39,12 +40,23 @@ process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: an
|
|
|
39
40
|
instanceId: String(ctx.params.instanceId ?? ''),
|
|
40
41
|
headers: (ctx.request?.headers ?? {}) as Record<string, unknown>,
|
|
41
42
|
body: ctx.request?.body,
|
|
43
|
+
/*
|
|
44
|
+
* 받은 바이트 그대로 — 서명을 확인하려면 이것이 있어야 한다. `koa-bodyparser` 가 파싱한 본문과
|
|
45
|
+
* 함께 남겨 준다. 파싱본을 다시 문자열로 만들면 키 순서와 공백이 달라져 서명이 맞지 않는다.
|
|
46
|
+
*/
|
|
47
|
+
rawBody: typeof ctx.request?.rawBody === 'string' ? ctx.request.rawBody : undefined,
|
|
48
|
+
store: databaseStore(),
|
|
42
49
|
/*
|
|
43
50
|
* 폴링이 쓰는 길을 그대로 쓴다 — 검증도 유입도 같은 자리다. 그래서 트윈 쪽에서는 물어서 받은
|
|
44
51
|
* 것과 밀어 받은 것이 구별되지 않는다. 구별되는 것은 유입 장부의 `sources` 한 줄뿐이다.
|
|
45
52
|
*/
|
|
46
53
|
ingest: (instanceId, records) => {
|
|
47
|
-
const { accepted, rejected, masterData } = ingestCanonicalRecords(
|
|
54
|
+
const { accepted, rejected, masterData } = ingestCanonicalRecords(
|
|
55
|
+
records as any,
|
|
56
|
+
domain.id,
|
|
57
|
+
new Date().toISOString(),
|
|
58
|
+
TwinEngine.factScope(domain.id, instanceId)
|
|
59
|
+
)
|
|
48
60
|
/* 사건이 아닌 것은 상태만 세운다 — 밀어 받는 길도 같은 규율이다(§`applyMasterData`). */
|
|
49
61
|
if (masterData.length) TwinEngine.applyMasterData(domain.id, instanceId, masterData)
|
|
50
62
|
const { applied, duplicates } = TwinEngine.ingestLiveResult(domain.id, instanceId, accepted, 'hook')
|
|
@@ -54,7 +66,11 @@ process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: an
|
|
|
54
66
|
*/
|
|
55
67
|
const undelivered = Math.max(0, accepted.length - applied - duplicates)
|
|
56
68
|
TwinEngine.recordIngestResult(domain.id, instanceId, records.length, rejected, Date.now(), undelivered)
|
|
57
|
-
|
|
69
|
+
/*
|
|
70
|
+
* 떨어진 것을 **수가 아니라 목록으로** 넘긴다 — 응답에 실어야 보내는 쪽이 그것만 따로 둘 수
|
|
71
|
+
* 있다. 수만 주면 배치 전체를 다시 보내거나 전부 버리는 두 갈래밖에 없다.
|
|
72
|
+
*/
|
|
73
|
+
return { applied, duplicates, rejected }
|
|
58
74
|
}
|
|
59
75
|
})
|
|
60
76
|
ctx.status = outcome.status
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **승인을 지난 커맨드만 어댑터에 넘긴다** — 게이트가 서는 자리.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이 자리인가 (2026-08-31) ──────────────────────────────────────────
|
|
5
|
+
* 설계가 「기본 = propose-only」를 정해 두었는데 코드에 그 규율이 없었다. 인티그레이션 레인이 어댑터에
|
|
6
|
+
* 조치를 붙이려다 그것을 보고 멈췄다 — 게이트 없이 붙이면 트윈이 사람 승인 없이 현장에 작업지시를
|
|
7
|
+
* 내린다. 작업지시는 취소해도 이미 만든 것이 남는다.
|
|
8
|
+
*
|
|
9
|
+
* ── 확인을 여기서 한 번만 한다 ───────────────────────────────────────────
|
|
10
|
+
* 어댑터는 승인을 확인하지 않는다. 그 확인은 이 파일이 했고 **타입이 그것을 증명한다**
|
|
11
|
+
* (`ApprovedCommand` 는 `approveCommand`·`asApproved` 로만 만들어진다). 어댑터가 또 확인하면 규칙이
|
|
12
|
+
* 두 벌이 되고, 한쪽만 고쳐지는 날이 온다.
|
|
13
|
+
*
|
|
14
|
+
* ── 저장소에 닿는 자리를 밖으로 낸다 ─────────────────────────────────────
|
|
15
|
+
* 훅에서와 같은 이유다(§`HookStore`). 시험이 **재기동을 넣을 수 있어야** 한다 — 승인이 프로세스 안에만
|
|
16
|
+
* 살아 있으면 「승인된다」는 보이지만 「다시 세운 뒤에도 승인이 남아 있나」는 보이지 않는다.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
approveCommand,
|
|
20
|
+
asApproved,
|
|
21
|
+
nextCommandState,
|
|
22
|
+
passThroughReversible,
|
|
23
|
+
requiresApproval,
|
|
24
|
+
type ApprovedCommand,
|
|
25
|
+
type CommandApproval,
|
|
26
|
+
type TwinCommand
|
|
27
|
+
} from '@operato/ops-contract'
|
|
28
|
+
|
|
29
|
+
/** 조치의 결과 — 어댑터가 돌려준다. */
|
|
30
|
+
export interface CommandAck {
|
|
31
|
+
ok: boolean
|
|
32
|
+
/** 저쪽 문서 · 작업 식별자. 되돌릴 때 무엇을 되돌릴지 말하려면 이것이 있어야 한다. */
|
|
33
|
+
ref?: string
|
|
34
|
+
error?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 커맨드가 사는 자리 — 재기동을 넘어야 한다. */
|
|
38
|
+
export interface CommandStore {
|
|
39
|
+
load(domainId: string, id: string): Promise<TwinCommand | null>
|
|
40
|
+
save(domainId: string, command: TwinCommand): Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 승인한다.
|
|
45
|
+
*
|
|
46
|
+
* 승인할 수 없는 상태면 계약이 던진다 — 여기서 답으로 돌려주지 않는 이유는, 실패를 답으로 주면 부르는
|
|
47
|
+
* 쪽이 그것을 무시할 수 있기 때문이다.
|
|
48
|
+
*/
|
|
49
|
+
export async function approve(
|
|
50
|
+
store: CommandStore,
|
|
51
|
+
domainId: string,
|
|
52
|
+
id: string,
|
|
53
|
+
approval: CommandApproval
|
|
54
|
+
): Promise<TwinCommand> {
|
|
55
|
+
const command = await store.load(domainId, id)
|
|
56
|
+
if (!command) throw new Error(`모르는 커맨드다 (${id})`)
|
|
57
|
+
|
|
58
|
+
const approved = approveCommand(command, approval)
|
|
59
|
+
await store.save(domainId, approved)
|
|
60
|
+
return approved
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 거절한다 — 여기서 끝난다. 되살아나지 않는다. */
|
|
64
|
+
export async function reject(
|
|
65
|
+
store: CommandStore,
|
|
66
|
+
domainId: string,
|
|
67
|
+
id: string,
|
|
68
|
+
approval: CommandApproval
|
|
69
|
+
): Promise<TwinCommand> {
|
|
70
|
+
const command = await store.load(domainId, id)
|
|
71
|
+
if (!command) throw new Error(`모르는 커맨드다 (${id})`)
|
|
72
|
+
|
|
73
|
+
const to = nextCommandState(command.state, 'reject')
|
|
74
|
+
if (!to) throw new Error(`${command.state} 상태의 커맨드는 거절할 수 없다 (${id})`)
|
|
75
|
+
|
|
76
|
+
const next: TwinCommand = { ...command, state: to, approval }
|
|
77
|
+
await store.save(domainId, next)
|
|
78
|
+
return next
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 어댑터에 넘긴다 — **승인을 지나지 않은 것은 여기서 막힌다.**
|
|
83
|
+
*
|
|
84
|
+
* `actuate` 는 `ApprovedCommand` 만 받으므로, 승인 없이 이 함수를 지나가는 길이 없다. 저장소에서
|
|
85
|
+
* 되세운 커맨드는 타입이 지워진 채로 오므로 `asApproved` 로 다시 확인한다 — 확인 없이 표식을 붙이면
|
|
86
|
+
* **재기동이 게이트를 여는 길**이 된다.
|
|
87
|
+
*
|
|
88
|
+
* 되돌릴 수 있는 커맨드는 사람 없이 지난다(시뮬·what-if). 그 판단도 계약이 한다.
|
|
89
|
+
*/
|
|
90
|
+
export async function dispatch(
|
|
91
|
+
store: CommandStore,
|
|
92
|
+
domainId: string,
|
|
93
|
+
id: string,
|
|
94
|
+
actuate: (command: ApprovedCommand) => Promise<CommandAck>
|
|
95
|
+
): Promise<TwinCommand> {
|
|
96
|
+
const command = await store.load(domainId, id)
|
|
97
|
+
if (!command) throw new Error(`모르는 커맨드다 (${id})`)
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* 되돌릴 수 있는 것은 제안 상태에서 바로 지난다. 되돌릴 수 없는 것은 승인 기록이 있어야 하고,
|
|
101
|
+
* 없으면 여기서 던진다 — 조용히 건너뛰면 그 커맨드가 승인 없이 나간 것이 된다.
|
|
102
|
+
*/
|
|
103
|
+
const approved: ApprovedCommand =
|
|
104
|
+
command.state === 'proposed' && !requiresApproval(command) ? passThroughReversible(command) : asApproved(command)
|
|
105
|
+
|
|
106
|
+
const to = nextCommandState(approved.state, 'dispatch')
|
|
107
|
+
if (!to) throw new Error(`${approved.state} 상태의 커맨드는 넘길 수 없다 (${id})`)
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
* **넘기기 전에 「넘기는 중」을 먼저 적는다.** 넘기고 나서 적으면, 그 사이에 프로세스가 죽었을 때
|
|
111
|
+
* 저쪽은 받았는데 우리는 안 보낸 줄 알고 다시 보낸다. 현장이 두 번 움직인다.
|
|
112
|
+
*/
|
|
113
|
+
const dispatching: TwinCommand = { ...approved, state: to }
|
|
114
|
+
await store.save(domainId, dispatching)
|
|
115
|
+
|
|
116
|
+
let ack: CommandAck
|
|
117
|
+
try {
|
|
118
|
+
ack = await actuate(approved)
|
|
119
|
+
} catch (e: any) {
|
|
120
|
+
const failed: TwinCommand = { ...dispatching, state: 'failed', error: String(e?.message ?? e) }
|
|
121
|
+
await store.save(domainId, failed)
|
|
122
|
+
return failed
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const settled: TwinCommand = ack?.ok
|
|
126
|
+
? { ...dispatching, state: 'acked', dispatchRef: ack.ref }
|
|
127
|
+
: { ...dispatching, state: 'failed', error: ack?.error ?? '어댑터가 이유를 말하지 않았다' }
|
|
128
|
+
await store.save(domainId, settled)
|
|
129
|
+
return settled
|
|
130
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 커맨드가 저장소에 닿는 두 자리 — 찾는 것과 적는 것.
|
|
3
|
+
*
|
|
4
|
+
* 디스패처가 엔티티를 직접 가져오면 그 파일을 부르는 시험이 TypeORM 데코레이터를 지나게 되고, 시험이
|
|
5
|
+
* 서지 않는다. 그러면 **게이트의 배선을 아무 시험도 걷지 않게 된다** — 훅에서 이미 겪은 부류다
|
|
6
|
+
* (§`hook-store.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import { getRepository } from '@things-factory/shell'
|
|
9
|
+
|
|
10
|
+
import type { TwinCommand } from '@operato/ops-contract'
|
|
11
|
+
|
|
12
|
+
import { hydratedDate } from '../../engine/read-time.js'
|
|
13
|
+
|
|
14
|
+
import { TwinCommandRecord } from './twin-command.js'
|
|
15
|
+
import type { CommandStore } from './command-dispatcher.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 행 → 커맨드. 상태 어휘는 계약이 정한 문자열 그대로다.
|
|
19
|
+
*
|
|
20
|
+
* `createdAt` 은 드라이버가 준 값이라 **직접 날짜로 만들지 않는다** — 드라이버마다 모양이 다르고,
|
|
21
|
+
* 문자열로 오는 드라이버에서는 시간대가 없는 값이 된다. `hydratedDate` 가 그 컬럼의 정의를 보고 푼다.
|
|
22
|
+
*/
|
|
23
|
+
function toCommand(repo: any, row: TwinCommandRecord): TwinCommand {
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
instanceId: row.instanceId,
|
|
27
|
+
type: row.type,
|
|
28
|
+
...(row.payload ? { payload: row.payload } : {}),
|
|
29
|
+
origin: row.origin as TwinCommand['origin'],
|
|
30
|
+
proposedAt: hydratedDate(repo, 'createdAt', row.createdAt)?.toISOString() ?? '',
|
|
31
|
+
/* 없는 것을 거짓으로 채우지 않는다 — 비어 있음이 「되돌릴 수 없다」의 뜻이다. */
|
|
32
|
+
...(row.reversible === null || row.reversible === undefined ? {} : { reversible: row.reversible }),
|
|
33
|
+
state: row.state as TwinCommand['state'],
|
|
34
|
+
...(row.decidedBy ? { approval: { by: row.decidedBy, at: row.decidedAt ?? '', ...(row.decisionNote ? { note: row.decisionNote } : {}) } } : {}),
|
|
35
|
+
...(row.dispatchRef ? { dispatchRef: row.dispatchRef } : {}),
|
|
36
|
+
...(row.error ? { error: row.error } : {})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 실제 저장소를 쓰는 구현 — 리졸버가 이것을 넣는다. */
|
|
41
|
+
export function databaseCommandStore(): CommandStore {
|
|
42
|
+
return {
|
|
43
|
+
load: async (domainId: string, id: string) => {
|
|
44
|
+
const repo = await getRepository(TwinCommandRecord)
|
|
45
|
+
const row = await repo.findOne({ where: { domain: { id: domainId }, id } }).catch(() => null)
|
|
46
|
+
return row ? toCommand(repo, row) : null
|
|
47
|
+
},
|
|
48
|
+
save: async (domainId: string, command: TwinCommand) => {
|
|
49
|
+
await getRepository(TwinCommandRecord).update(
|
|
50
|
+
{ id: command.id, domain: { id: domainId } },
|
|
51
|
+
{
|
|
52
|
+
state: command.state,
|
|
53
|
+
decidedBy: command.approval?.by ?? null,
|
|
54
|
+
decidedAt: command.approval?.at ?? null,
|
|
55
|
+
decisionNote: command.approval?.note ?? null,
|
|
56
|
+
dispatchRef: command.dispatchRef ?? null,
|
|
57
|
+
error: command.error ?? null
|
|
58
|
+
} as any
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|