@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
package/test/axis-read.test.ts
CHANGED
|
@@ -40,7 +40,7 @@ test('상태가 없으면 모른다 — 커널이 멈춰 있으면 0 이 아니
|
|
|
40
40
|
assert.equal(axisValueOf(undefined, { axis: 'demandWindows', path: 'energy.closed' }), undefined)
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
test('경로 따라가기는 중간 부재에서 멈춘다 —
|
|
43
|
+
test('경로 따라가기는 중간 부재에서 멈춘다 — 오류를 내지 않는다', () => {
|
|
44
44
|
assert.equal(atPath({ a: { b: 1 } }, 'a.b'), 1)
|
|
45
45
|
assert.equal(atPath({ a: null }, 'a.b'), undefined)
|
|
46
46
|
assert.equal(atPath(undefined, 'a.b'), undefined)
|
|
@@ -85,3 +85,103 @@ test('커널 선언과 상태의 모양이 맞는다 — 선언한 경로에 정
|
|
|
85
85
|
assert.equal(Array.isArray(windows), true, '선언한 경로에 목록이 있어야 한다')
|
|
86
86
|
assert.equal(windows.length, 1, '마감된 구간 하나')
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* ── 규칙의 집이 하나인지 **기계가 지킨다** (2026-08-21) ──────────────────────
|
|
91
|
+
*
|
|
92
|
+
* 위 단위 시험은 처음부터 초록이었다. 함수는 옳았고, **호출부 하나가 그 함수를 지나지 않았다** —
|
|
93
|
+
* `twin-model-item-query.ts` 가 `(snap as any)[axis]` 로 축 이름을 직접 읽었다. 그래서 개념 지도의
|
|
94
|
+
* 카드는 수요 구간을 **42** 로 세는데 그 개념을 열면 **0건**이 나왔다. 사용자에게는 「트윈이 비어
|
|
95
|
+
* 있다」로 읽힌다.
|
|
96
|
+
*
|
|
97
|
+
* 그 함정은 형제 파일 주석에 이미 적혀 있었다(「축 이름으로만 읽으면 중첩된 축이 언제나 관측 없음이
|
|
98
|
+
* 된다 — 에너지의 수요 구간이 그렇다」). **적어 두는 것으로는 막히지 않았다.** 그래서 센다.
|
|
99
|
+
*/
|
|
100
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
101
|
+
import { createRequire } from 'node:module'
|
|
102
|
+
import { join } from 'node:path'
|
|
103
|
+
import { fileURLToPath } from 'node:url'
|
|
104
|
+
|
|
105
|
+
const SVC = fileURLToPath(new URL('../server/service/twin-model', import.meta.url))
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 상태에서 축을 **이름으로** 읽는 모양 — `snap[axis]` · `(state as any)[axis]` 류.
|
|
109
|
+
*
|
|
110
|
+
* 앞에 `\b` 를 둔다: 처음에는 없어서 `const` 안의 `st` 를 잡아 `ROW_BACKED[axis]`(조회표이지 상태
|
|
111
|
+
* 읽기가 아니다)를 위반으로 냈다. **거짓 빨강을 내는 가드는 없는 것보다 나쁘다** — 이름이 근거다.
|
|
112
|
+
*/
|
|
113
|
+
const BY_NAME = /\(?\s*\b(?:snap|state)\b[^)\n]*\)?\s*\[\s*axis\s*\]/g
|
|
114
|
+
|
|
115
|
+
test('축을 이름으로 읽는 자리가 없다 — 선언된 경로를 지나야 중첩된 축이 보인다', () => {
|
|
116
|
+
const offenders: string[] = []
|
|
117
|
+
for (const f of readdirSync(SVC).filter(n => n.endsWith('.ts'))) {
|
|
118
|
+
const src = readFileSync(join(SVC, f), 'utf8')
|
|
119
|
+
for (const line of src.split('\n')) {
|
|
120
|
+
/* 주석은 규칙이 아니라 설명이다 — 함정을 적어 둔 줄이 스스로 위반으로 잡히면 신호가 묻힌다. */
|
|
121
|
+
const code = line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '')
|
|
122
|
+
if (code.trimStart().startsWith('*')) continue
|
|
123
|
+
if (BY_NAME.test(code)) offenders.push(`${f}: ${line.trim()}`)
|
|
124
|
+
BY_NAME.lastIndex = 0
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
offenders,
|
|
129
|
+
[],
|
|
130
|
+
`축을 이름으로 읽는 자리가 있다 — \`axisValueOf(state, 축선언)\` 을 쓰십시오.\n${offenders.join('\n')}`
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('수요 구간은 실제로 중첩된 축이다 — 이 가드가 지키는 대상이 있다', () => {
|
|
135
|
+
/* 가드가 지킬 것이 없으면 그 가드는 초록으로 거짓을 말한다. 대상이 실재하는지 함께 본다. */
|
|
136
|
+
const req = createRequire(import.meta.url)
|
|
137
|
+
const { TWIN_AXES } = req('@operato/twin-kernel')
|
|
138
|
+
const nested = (TWIN_AXES as any[]).filter(a => a.path)
|
|
139
|
+
|
|
140
|
+
assert.ok(nested.length > 0, '경로를 가진 축이 하나도 없으면 이 가드는 의미가 없다')
|
|
141
|
+
assert.ok(
|
|
142
|
+
nested.some(a => a.axis === 'demandWindows'),
|
|
143
|
+
`수요 구간이 경로 축이어야 한다 — 실제 경로 축: ${nested.map(a => a.axis).join(', ')}`
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/*
|
|
148
|
+
* ── 무엇이 항목을 가리키는지도 **선언이 말한다** (2026-08-21) ─────────────────
|
|
149
|
+
*
|
|
150
|
+
* 개념 지도의 수요 구간을 열면 여섯 구간이 모두 「식별자 없음 — 참조할 수 없는 항목입니다」로 보였다.
|
|
151
|
+
* 값(구간 시작·종료·최대수요)은 실재하는데 **가리킬 수 없다고 말한 것**이다.
|
|
152
|
+
*
|
|
153
|
+
* 원인은 축이 사는 자리를 짐작했던 것과 **같은 부류**다: 소비처가 `id`·`key`·`gtin` 을 순서대로
|
|
154
|
+
* 짐작했고, 수요 구간에는 그 셋이 없다. 그것은 결함이 아니다 — 구간의 정체성은 **언제 시작했나**이고
|
|
155
|
+
* 커널도 그것으로 키를 만든다(`contract-projected-over:<startMs>`).
|
|
156
|
+
*/
|
|
157
|
+
test('id 가 없는 상태 축은 무엇이 항목을 가리키는지 선언한다', () => {
|
|
158
|
+
const req2 = createRequire(import.meta.url)
|
|
159
|
+
const { TWIN_AXES } = req2('@operato/twin-kernel')
|
|
160
|
+
const dw = (TWIN_AXES as any[]).find(a => a.axis === 'demandWindows')
|
|
161
|
+
|
|
162
|
+
assert.ok(dw, '수요 구간 축이 있어야 한다')
|
|
163
|
+
assert.equal(dw.idField, 'startMs', '구간의 정체성은 시작 시각이다')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('항목 질의가 짐작하지 않는다 — 선언된 자리를 읽는다', () => {
|
|
167
|
+
/*
|
|
168
|
+
* 기계가 지킨다. 주석에 적어 두는 것으로는 막히지 않는 것을 오늘 이미 겪었다(축이 사는 자리를
|
|
169
|
+
* 짐작한 자리 셋이 형제 파일 주석의 경고에도 남아 있었다).
|
|
170
|
+
*/
|
|
171
|
+
/*
|
|
172
|
+
* ── **파일 이름에 묶지 않는다** (2026-08-22) ────────────────────────────────
|
|
173
|
+
* 예전에는 `twin-model-item-query.ts` 하나만 읽었다. 다른 레인이 `idOf` 를 `name-index.ts` 로
|
|
174
|
+
* 옮기자(기능은 배열 차례까지 온전했다) 이 가드가 **거짓 빨강**을 냈다. 가드가 지켜야 하는 것은
|
|
175
|
+
* 「그 파일에 그 낱말이 있나」가 아니라 「**이 서비스 어딘가에서 선언된 식별 자리를 읽나**」다.
|
|
176
|
+
*
|
|
177
|
+
* 그래서 서비스 폴더를 훑는다 — 옮겨도 따라간다. 옮길 자유를 막는 가드는 사람을 가드와 싸우게 만든다.
|
|
178
|
+
*/
|
|
179
|
+
const sources = readdirSync(SVC, { recursive: true } as any)
|
|
180
|
+
.filter((f: any) => String(f).endsWith('.ts'))
|
|
181
|
+
.map((f: any) => readFileSync(join(SVC, String(f)), 'utf8'))
|
|
182
|
+
const all = sources.join('\n')
|
|
183
|
+
|
|
184
|
+
assert.match(all, /idField/, '선언된 식별 자리를 읽어야 한다(이 서비스 어딘가에서)')
|
|
185
|
+
/* `x?.id === itemId` 로 찾던 자리가 남아 있으면 그 축의 항목은 열 수 없다. */
|
|
186
|
+
assert.doesNotMatch(all, /find\(\(x: any\) => x\?\.id === itemId\)/, 'id 로만 찾는 자리가 남지 않았다')
|
|
187
|
+
})
|
package/test/boot-resume.test.ts
CHANGED
|
@@ -37,23 +37,79 @@ function methodOf(src: string, name: string): string {
|
|
|
37
37
|
test('부팅이 런타임까지 되살린다 — 상태 복구만으로는 도는 트윈이 아니다', () => {
|
|
38
38
|
const body = methodOf(engine, 'bootstrap')
|
|
39
39
|
assert.match(body, /resumeRow\(row\)/, 'bootstrap 이 행마다 재개를 시도해야 한다')
|
|
40
|
-
/* 예전 모양: 스냅샷을 `recovered` 에 담고 `continue` 로 끝났다 — 그것으로 되돌아가지 않게.
|
|
41
|
-
|
|
40
|
+
/* 예전 모양: 스냅샷을 `recovered` 에 담고 `continue` 로 끝났다 — 그것으로 되돌아가지 않게.
|
|
41
|
+
한 줄로 못 박지 않는다(2026-08-21 에 되살리기 사이 양보가 들어와 줄 모양이 바뀌었다) —
|
|
42
|
+
**행 전부를 돌며 재개한다**는 사실만 본다. */
|
|
43
|
+
assert.match(body, /for \(const row of rows\)[\s\S]{0,400}?await this\.resumeRow\(row\)/, '복구 뒤 재개 루프가 있어야 한다')
|
|
44
|
+
/* 되살리기 사이에 루프를 비워 준다 — 없으면 부팅 내내 HTTP 가 서지 않는다. */
|
|
45
|
+
assert.match(body, /BOOT_YIELD_MS/, '되살리기 사이에 양보가 있어야 한다')
|
|
42
46
|
})
|
|
43
47
|
|
|
44
48
|
test('모드를 지어내지 않는다 — 미러였던 것은 미러로 되살린다', () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
/*
|
|
50
|
+
* ── 갈림은 **문 안**에 있다 (2026-08-20) ──────────────────────────────────
|
|
51
|
+
* 예전에는 이 판단이 `resumeRow` 에 있었다. 그래서 부팅으로 살아난 미러는 관측 구동이었지만, 사람이
|
|
52
|
+
* 화면에서 시작한 미러는 **시뮬로 떴다** — 그 문(`startFromRegistry`)에는 갈림이 없었기 때문이다.
|
|
53
|
+
* 시뮬로 뜬 미러는 유입을 한 건도 받지 못하고(`ingestLive` 가 0 을 돌려준다), 화면에는 「running」이라
|
|
54
|
+
* 적힌다. 같은 선언이 두 결과를 내지 않도록 갈림을 문 안으로 옮겼고, 이 시험이 그 자리를 지킨다.
|
|
55
|
+
*/
|
|
56
|
+
const resumeRow = methodOf(engine, 'resumeRow')
|
|
57
|
+
assert.match(resumeRow, /await this\.startFromRegistry\(domainId, instanceId\)/, '부팅도 같은 문을 지난다')
|
|
58
|
+
|
|
59
|
+
const door = methodOf(engine, 'startFromRegistry')
|
|
60
|
+
assert.match(door, /const policy = restartPolicy \?\? readRestartPolicy\(/, '선언된 값을 엄격히 읽는다')
|
|
61
|
+
assert.match(door, /if \(policy === 'resync'\)/, '문이 선언된 모드를 보고 갈라야 한다')
|
|
49
62
|
/*
|
|
50
63
|
* 미러 가지 안을 그대로 떠서 본다 — 미러를 시뮬로 세우면 관측 트윈이 **스스로 물건을 옮긴다**
|
|
51
64
|
* (없던 움직임을 만드는 것이다). 단어의 거리로 재면 로그·주석에 걸려 헛돈다.
|
|
52
65
|
*/
|
|
53
|
-
const at =
|
|
54
|
-
const mirrorBranch =
|
|
55
|
-
assert.match(mirrorBranch, /startLive\(/, '미러 가지는 관측 구동으로
|
|
56
|
-
assert.equal(mirrorBranch.includes('
|
|
66
|
+
const at = door.indexOf("if (policy === 'resync')")
|
|
67
|
+
const mirrorBranch = door.slice(at, door.indexOf('\n }', at))
|
|
68
|
+
assert.match(mirrorBranch, /return this\.startLive\(/, '미러 가지는 관측 구동으로 세우고 거기서 끝난다')
|
|
69
|
+
assert.equal(mirrorBranch.includes('this.start('), false, '미러 가지에서 시뮬 기동을 부른다 — 없던 움직임을 만든다')
|
|
70
|
+
/* 미러도 공간의 시각 기준을 받는다 — 교대의 HH:MM 을 어느 기준으로 읽나(부팅과 같은 규칙). */
|
|
71
|
+
assert.match(mirrorBranch, /withSpaceTimeBase\(/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('reset 은 선언대로 저널을 비운다 — 씨앗 재현은 백지에서 시작한다', () => {
|
|
75
|
+
/*
|
|
76
|
+
* 「seed 재현」은 백지에서 다시 시작한다는 뜻인데, 예전에는 그 선언이 구현되지 않아 `reset` 트윈이
|
|
77
|
+
* 사실상 이어졌다. 사실을 지우는 동작이라 순서를 지켜 켰다: 먼저 사람이 정책을 다시 선언할 문을 만들고
|
|
78
|
+
* (`setTwinRestartPolicy`), 이력을 두고 볼 트윈을 `resume` 으로 옮긴 뒤에 켰다.
|
|
79
|
+
*/
|
|
80
|
+
const door = methodOf(engine, 'startFromRegistry')
|
|
81
|
+
const at = door.indexOf("if (policy === 'reset')")
|
|
82
|
+
assert.ok(at > 0, '문이 reset 을 갈라야 한다')
|
|
83
|
+
const branch = door.slice(at, door.indexOf('\n }', at))
|
|
84
|
+
assert.match(branch, /resetJournal\(domainId, instanceId\)/, '저널을 비운다')
|
|
85
|
+
/* 벤치 사본은 예외다 — 누군가 지켜보는 실험의 기록을 기동이 지우면 실험이 사라진다. */
|
|
86
|
+
assert.match(branch, /purpose !== 'bench'/, '벤치 실험의 기록은 지우지 않는다')
|
|
87
|
+
/* 웜스타트보다 **먼저** 지워야 한다 — 뒤에 지우면 이미 옛 상태를 읽어 실은 뒤다. */
|
|
88
|
+
assert.ok(at < door.indexOf('this.recovered[key]'), '지우기가 웜스타트보다 앞에 있어야 한다')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('저널만 지우면 씨앗 재현이 아니다 — 체크포인트·사슬도 무효로 만든다', () => {
|
|
92
|
+
/*
|
|
93
|
+
* 웜스타트는 체크포인트를 **먼저** 본다. 저널만 지우면 백지로 시작한 트윈에 옛 상태가 되살아나
|
|
94
|
+
* 「저널은 비었는데 재고가 있는」 트윈이 된다. 시간여행이 딛는 사슬 캐시도 지워진 리비전을 가리킨다.
|
|
95
|
+
*/
|
|
96
|
+
const body = methodOf(engine, 'resetJournal')
|
|
97
|
+
assert.match(body, /SNAPSHOT_CACHE_ID[\s\S]{0,120}state: null/, '체크포인트를 「없음」으로 덮는다')
|
|
98
|
+
assert.match(body, /CHAIN_INDEX_CACHE_ID[\s\S]{0,80}revisions: \[\]/, '사슬 색인도 비운다')
|
|
99
|
+
/* 실패를 삼키지 않는다 — 무효화가 실패하면 다음 기동이 옛 상태를 물려받는다. */
|
|
100
|
+
assert.match(body, /catch[\s\S]{0,120}twinWarn/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('정책을 다시 선언하는 문이 있다 — 없으면 선언과 쓰임이 영원히 어긋난다', () => {
|
|
104
|
+
const src = readFileSync(
|
|
105
|
+
fileURLToPath(new URL('../server/service/twin-lifecycle/twin-lifecycle-mutation.ts', import.meta.url)),
|
|
106
|
+
'utf-8'
|
|
107
|
+
)
|
|
108
|
+
assert.match(src, /async setTwinRestartPolicy\(/, '만든 뒤에 고칠 길이 있어야 한다')
|
|
109
|
+
/* 값은 엄격히 읽는다 — 모르는 말을 기본값으로 메우면 그 트윈의 이력이 남을지가 조용히 결정된다. */
|
|
110
|
+
assert.match(src, /readRestartPolicy\(restartPolicy, /)
|
|
111
|
+
/* 도는 런타임을 조용히 갈아 끼우지 않는다 — 다르다는 사실을 답에 담는다. */
|
|
112
|
+
assert.match(src, /appliesAtNextStart/)
|
|
57
113
|
})
|
|
58
114
|
|
|
59
115
|
test('되살리지 못한 행은 stopped 로 적는다 — 「도는 중」이라 말하는 채로 두지 않는다', () => {
|
|
@@ -61,7 +117,7 @@ test('되살리지 못한 행은 stopped 로 적는다 — 「도는 중」이
|
|
|
61
117
|
assert.match(resume, /catch[\s\S]{0,80}markStopped/, '실패를 삼키면 등록부가 계속 거짓을 말한다')
|
|
62
118
|
const mark = methodOf(engine, 'markStopped')
|
|
63
119
|
assert.match(mark, /status: 'stopped'/, '등록부에 실제로 적어야 한다')
|
|
64
|
-
assert.match(mark, /
|
|
120
|
+
assert.match(mark, /twin(Warn|Error)/, '되살리지 못한 사실은 로그에도 남아야 한다')
|
|
65
121
|
})
|
|
66
122
|
|
|
67
123
|
test('벤치 사본은 무인으로 되살아나지 않는다 — 실험은 재기동을 넘기지 못한다', () => {
|
|
@@ -87,36 +143,46 @@ test('부팅 훅이 엔진 다음에 피드를 붙인다 — 순서가 뒤집히
|
|
|
87
143
|
|
|
88
144
|
/*
|
|
89
145
|
* ── 부팅 재개가 드러낸 것 (2026-08-14 실측) ─────────────────────────────────
|
|
90
|
-
* 트윈 14개가 실제로 돌기 시작하자 호스트가 죽었다. 원인은 재개가 아니라
|
|
91
|
-
* ① 시뮬은 delta 마다 곧바로 전 상태를 투영해
|
|
146
|
+
* 트윈 14개가 실제로 돌기 시작하자 호스트가 죽었다. 원인은 재개가 아니라 **브로드캐스팅 두 가지**였다:
|
|
147
|
+
* ① 시뮬은 delta 마다 곧바로 전 상태를 투영해 브로드캐스팅했다 — 한 tick 이 사실을 수백 개 내면 그 반복이
|
|
92
148
|
* 이벤트 루프를 35초 막았고(단계 합은 32ms 였다), 그 사이 구독자는 아무것도 빼내지 못했다.
|
|
93
|
-
* ② 밀린 push 가 1024를 넘자 pubsub 이
|
|
149
|
+
* ② 밀린 push 가 1024를 넘자 pubsub 이 오류를 냈고, 타이머 콜백을 타고 올라온 그 예외가 프로세스를 끝냈다.
|
|
94
150
|
* 둘 다 재개 없이도 있던 결함이다 — 재개가 그것을 매일 아침 만나게 만들었을 뿐이다.
|
|
95
151
|
*/
|
|
96
|
-
test('시뮬
|
|
152
|
+
test('시뮬 브로드캐스팅도 모아서 한 번 — delta 마다 전 상태를 투영하지 않는다', () => {
|
|
97
153
|
const at = engine.indexOf("if (msg.kind === 'delta')")
|
|
98
154
|
assert.ok(at >= 0, 'sim delta 처리 자리가 없다')
|
|
99
|
-
const body = engine.slice(at, at +
|
|
155
|
+
const body = engine.slice(at, at + 2600)
|
|
100
156
|
assert.match(body, /inst\.dirty = true/, 'dirty 로 표시하고')
|
|
101
157
|
assert.match(body, /ensureBroadcastCoalescer\(\)/, '주기 flush 에 맡겨야 한다')
|
|
102
|
-
assert.equal(/this\.publishEntityData\(inst\)/.test(body), false, 'delta 마다 곧바로
|
|
158
|
+
assert.equal(/this\.publishEntityData\(inst\)/.test(body), false, 'delta 마다 곧바로 브로드캐스팅하는 모양으로 되돌아갔다')
|
|
103
159
|
})
|
|
104
160
|
|
|
105
|
-
test('flush 가 시뮬도 본다 —
|
|
161
|
+
test('flush 가 시뮬도 본다 — 브로드캐스팅과 저널은 둘 다, state 채널은 라이브만', () => {
|
|
106
162
|
const body = methodOf(engine, 'flushLiveBroadcasts')
|
|
107
163
|
assert.equal(/mode !== 'live'\) continue/.test(body), false, '시뮬을 건너뛰는 모양으로 되돌아갔다')
|
|
108
|
-
assert.match(body, /const isLive = inst\.mode === 'live'/, '모드는 가르되 둘 다
|
|
109
|
-
/*
|
|
110
|
-
|
|
164
|
+
assert.match(body, /const isLive = inst\.mode === 'live'/, '모드는 가르되 둘 다 브로드캐스팅해야 한다')
|
|
165
|
+
/*
|
|
166
|
+
* **저널은 두 구동이 같은 문을 쓴다** (2026-08-20, ingest-scale §7.1). 예전에는 시뮬이 델타마다 단건
|
|
167
|
+
* 저장을 했고 그것이 규모에서 호스트를 먹었다(CPU 100%·질의 8~14초). 그래서 flush 가 시뮬의 저널도
|
|
168
|
+
* 흘린다 — 그 호출이 `if (!isLive) continue` **앞**에 있어야 시뮬 몫이 빠지지 않는다.
|
|
169
|
+
*/
|
|
170
|
+
const flushAt = body.indexOf('this.flushJournal(inst)')
|
|
171
|
+
const liveOnlyAt = body.indexOf('if (!isLive) continue')
|
|
172
|
+
assert.ok(flushAt >= 0, '저널을 흘리는 자리가 없다')
|
|
173
|
+
assert.ok(liveOnlyAt >= 0, 'state 채널을 라이브로 가르는 자리가 없다')
|
|
174
|
+
assert.ok(flushAt < liveOnlyAt, '저널 flush 가 라이브 전용 구간 뒤로 밀리면 시뮬의 사실이 쌓인 채 남는다')
|
|
175
|
+
/* state 채널은 시뮬이 자기 콜백에서 이미 보낸다 — 여기서 또 보내면 같은 신호가 두 번 간다. */
|
|
176
|
+
assert.ok(body.indexOf("twinState: { instanceId: inst.id") > liveOnlyAt, 'state 채널 브로드캐스팅은 라이브 구간에 있어야 한다')
|
|
111
177
|
})
|
|
112
178
|
|
|
113
179
|
test('구독자 하나가 호스트를 죽이지 못한다 — 그리고 못 보낸 것을 보낸 것으로 적지 않는다', () => {
|
|
114
|
-
/* 직접
|
|
180
|
+
/* 직접 브로드캐스팅은 **가드 안에 하나**여야 한다 — 그 밖의 호출은 예외가 그대로 올라가는 길이다. */
|
|
115
181
|
const guardBody = methodOf(engine, 'publishGuarded')
|
|
116
|
-
assert.equal((engine.match(/pubsub\.publish\(/g) ?? []).length, 1, '가드를 거치지 않는 직접
|
|
182
|
+
assert.equal((engine.match(/pubsub\.publish\(/g) ?? []).length, 1, '가드를 거치지 않는 직접 브로드캐스팅이 남아 있다')
|
|
117
183
|
assert.match(guardBody, /pubsub\.publish\(/, '그 하나는 가드 안에 있어야 한다')
|
|
118
|
-
assert.match(guardBody, /catch/, '
|
|
119
|
-
assert.match(guardBody, /
|
|
184
|
+
assert.match(guardBody, /catch/, '브로드캐스팅 실패를 잡아야 한다(타이머 콜백에서 오류가 나면 프로세스가 끝난다)')
|
|
185
|
+
assert.match(guardBody, /twinWarn/, '못 보낸 사실은 남아야 한다(삼키면 「보냈는데 낡았다」가 된다)')
|
|
120
186
|
/* 시그니처를 남겨 두면 다음 주기에 「무변화」로 건너뛰고 그 태그는 영원히 낡는다. */
|
|
121
187
|
assert.match(engine, /if \(!this\.publishGuarded\('data'[\s\S]{0,80}sigs\.delete\(tag\)/, '실패한 태그의 시그니처를 되돌려야 한다')
|
|
122
188
|
})
|
|
@@ -124,7 +190,7 @@ test('구독자 하나가 호스트를 죽이지 못한다 — 그리고 못 보
|
|
|
124
190
|
/*
|
|
125
191
|
* ── 굶김 안전망 (2026-08-14) ─────────────────────────────────────────────────
|
|
126
192
|
* 시뮬 틱은 메인 이벤트 루프에서 돈다 — 한 트윈의 틱이 길어지면 그 시간만큼 호스트 전체가 멈춘다
|
|
127
|
-
* (실측 34.9초).
|
|
193
|
+
* (실측 34.9초). 브로드캐스팅 반복은 걷어냈지만 커널 틱 자체는 여전히 메인 루프에 있고, 근본 해결(분산)은
|
|
128
194
|
* 이연됐다. 그 사이의 안전망이 이것이다: **호스트를 굶기는 트윈은 멈춘다.**
|
|
129
195
|
*/
|
|
130
196
|
test('굶김은 예산과 다른 문턱으로 판정한다 — 조금 느린 트윈을 내리지 않는다', () => {
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **브로드캐스팅 한 번의 비용** — 200ms 마다 무엇을 만드는지 값으로 남긴다 (2026-08-21).
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 재는가 ───────────────────────────────────────────────────────────────
|
|
5
|
+
* `publishEntityData` 는 브로드캐스팅 주기마다 ① 상태를 통째로 투영하고(`getSnapshot`) ② 엔티티마다 payload 를
|
|
6
|
+
* 만들고(`buildEntityDeltas`) ③ 그 payload 를 문자열로 만들어 이전 것과 비교한다. ③ 의 비교가 없던
|
|
7
|
+
* 시절에는 바뀌지 않은 것까지 전송했고, 그 낭비는 2026-08-14 에 없앴다 — 그런데 없앤 것은 **전송**이고
|
|
8
|
+
* ①②③ 의 **계산은 그대로** 남았다. 물품이 수천 개인 트윈에서는 하나만 움직여도 수천 개를 만들어
|
|
9
|
+
* 문자열로 바꾼 뒤 「같다」를 확인하고 버린다.
|
|
10
|
+
*
|
|
11
|
+
* 고치기 전에 세 축의 몫을 값으로 남긴다. 짐작으로 고치면 무엇이 나아졌는지 말할 수 없다.
|
|
12
|
+
*
|
|
13
|
+
* ── 고친 뒤 (2026-08-21) ────────────────────────────────────────────────────
|
|
14
|
+
* 이제 브로드캐스팅은 **건드린 물품만** 만든다(`buildEntityDeltas` 의 `scope.items`). 그래서 이 시험은 두
|
|
15
|
+
* 창을 나란히 잰다: 범위를 모를 때(전부) 와 물품 하나를 건드렸을 때(좁힘).
|
|
16
|
+
*
|
|
17
|
+
* ── 게이트는 시간이 아니다 ──────────────────────────────────────────────────
|
|
18
|
+
* 기계마다·스위트 병렬 실행마다 시간은 달라진다(같은 코드에서 3.2배와 14.8배가 나온 적이 있다). 그래서
|
|
19
|
+
* 단정은 **시간과 무관한 사실**로 한다: 좁힌 창이 만든 물품 payload 가 말한 것 하나뿐인가. 시간은
|
|
20
|
+
* 기준선으로 출력만 하고, 여러 번 재 그중 **가장 짧은 값**을 적는다(GC·JIT 잡음을 덜 타는 값).
|
|
21
|
+
*/
|
|
22
|
+
import { test } from 'node:test'
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
|
|
25
|
+
import { MesKernel, type CanonicalEnvelope, type ProductionSpec, type ScenarioDef, type TwinModelDef } from '@operato/twin-kernel'
|
|
26
|
+
|
|
27
|
+
import { buildEntityDeltas } from '../shared/entity-delta.ts'
|
|
28
|
+
|
|
29
|
+
const COMMISSIONING = 'urn:epcglobal:cbv:bizstep:commissioning'
|
|
30
|
+
/* GS1 이 예제로 지정한 `952` 대역 — 실 배정 대역을 픽스처에 두지 않는다(CBV 2.0). */
|
|
31
|
+
const PREFIX = '9521321'
|
|
32
|
+
const RAW_GTIN = `urn:epc:idpat:sgtin:${PREFIX}.0001.*`
|
|
33
|
+
|
|
34
|
+
const DEFINITION: any = {
|
|
35
|
+
materials: [
|
|
36
|
+
{ key: 'raw', label: '원자재', locationType: 'raw-store' },
|
|
37
|
+
{ key: 'product', label: '완제품', locationType: 'fg-store' }
|
|
38
|
+
],
|
|
39
|
+
locationTypes: [
|
|
40
|
+
{ key: 'raw-store', label: '자재 보관' },
|
|
41
|
+
{ key: 'cut-st', label: '절단' },
|
|
42
|
+
{ key: 'fg-store', label: '완제품 보관' }
|
|
43
|
+
],
|
|
44
|
+
resourceTypes: [{ key: 'cutter', label: '절단기' }],
|
|
45
|
+
operations: [{ key: 'cut', label: '절단', intent: 'process', locationType: 'cut-st', resourceType: 'cutter', bizStep: COMMISSIONING }],
|
|
46
|
+
routes: [{ key: 'line', label: '라인', steps: ['cut'] }],
|
|
47
|
+
recipes: [{ key: 'prod', label: '완제품', inputs: [{ material: 'raw', qty: 1 }], outputs: [{ material: 'product', qty: 1 }], route: 'line' }]
|
|
48
|
+
}
|
|
49
|
+
const SPEC: ProductionSpec = {
|
|
50
|
+
definition: DEFINITION,
|
|
51
|
+
binding: { raw: '0001', product: '0002' },
|
|
52
|
+
identity: { documentTypes: { workorder: '403' }, namespaces: [`urn:epc:idpat:sgtin:${PREFIX}.`] },
|
|
53
|
+
companyPrefix: PREFIX
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function board(): TwinModelDef {
|
|
57
|
+
return {
|
|
58
|
+
locations: [
|
|
59
|
+
{ id: 'raw-store', type: 'raw-store', capacity: 1_000_000 },
|
|
60
|
+
{ id: 'cut-st', type: 'cut-st', capacity: 1_000_000 },
|
|
61
|
+
{ id: 'fg-store', type: 'fg-store', capacity: 1_000_000 }
|
|
62
|
+
],
|
|
63
|
+
equipment: Array.from({ length: 8 }, (_, i) => ({ id: `cutter-${i}`, kind: 'cutter', homeLocation: 'cut-st' }))
|
|
64
|
+
} as TwinModelDef
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scenario(ordersPerHour: number): ScenarioDef {
|
|
68
|
+
return {
|
|
69
|
+
seed: 9,
|
|
70
|
+
generators: [
|
|
71
|
+
{
|
|
72
|
+
kind: 'inbound-arrival',
|
|
73
|
+
stimulus: 'arrival',
|
|
74
|
+
rate: { distribution: 'constant', meanPerHour: ordersPerHour * 4 },
|
|
75
|
+
content: { skuMix: [{ gtin: RAW_GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } }
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
kind: 'workorder',
|
|
79
|
+
stimulus: 'order',
|
|
80
|
+
rate: { distribution: 'constant', meanPerHour: ordersPerHour },
|
|
81
|
+
content: { skuMix: [{ gtin: 'wo', weight: 1 }], qtyPerLine: { min: 1, max: 1 } }
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
} as ScenarioDef
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Row {
|
|
88
|
+
items: number
|
|
89
|
+
entities: number
|
|
90
|
+
payloads: number
|
|
91
|
+
/** 물품 하나만 건드렸다고 말했을 때 만드는 payload 수 — 좁히기가 듣는지. */
|
|
92
|
+
scopedPayloads: number
|
|
93
|
+
snapshotMs: number
|
|
94
|
+
/** 매핑 + 시그니처(다섯 번 중 가장 짧은 값). */
|
|
95
|
+
buildMs: number
|
|
96
|
+
broadcastMs: number
|
|
97
|
+
/** 좁힌 창의 매핑 + 시그니처 시간 — 투영은 그대로 남는다(커널 몫). */
|
|
98
|
+
scopedMs: number
|
|
99
|
+
/** 만든 payload 목록 — 단정이 「무엇을 만들었나」를 직접 본다(수만으로는 축을 구별하지 못한다). */
|
|
100
|
+
fullDeltas: any[]
|
|
101
|
+
scopedDeltas: any[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 한 규모에서 **브로드캐스팅 한 번**의 세 축을 잰다. 상태는 미리 쌓아 두고, 재는 것은 브로드캐스팅뿐이다. */
|
|
105
|
+
function measure(ticks: number, ordersPerHour: number): Row {
|
|
106
|
+
const k: any = new MesKernel('bench', undefined, SPEC)
|
|
107
|
+
k.onEvent((_e: CanonicalEnvelope) => {})
|
|
108
|
+
k.loadTwinModel(board())
|
|
109
|
+
k.scenario.load(scenario(ordersPerHour))
|
|
110
|
+
k.scenario.start()
|
|
111
|
+
for (let i = 0; i < ticks; i++) k.tick(1000)
|
|
112
|
+
|
|
113
|
+
/* ① 상태 투영 — 자리·물품·자원·작업·오더를 새 객체로 만든다. */
|
|
114
|
+
const t0 = performance.now()
|
|
115
|
+
const st = k.getSnapshot()
|
|
116
|
+
const t1 = performance.now()
|
|
117
|
+
/*
|
|
118
|
+
* ②③ payload 만들기 + 시그니처 — **다섯 번 재 가장 짧은 값을 쓴다.** 한 번만 재면 그 순간의 GC 가
|
|
119
|
+
* 값에 들어가, 좁힌 창이 전부 만든 창보다 오래 걸린 것처럼 보이는 일이 실제로 있었다.
|
|
120
|
+
*/
|
|
121
|
+
const pass = (scope?: { items: ReadonlySet<string> }) => {
|
|
122
|
+
let best = Infinity
|
|
123
|
+
let built: any[] = []
|
|
124
|
+
let chars = 0
|
|
125
|
+
for (let i = 0; i < 5; i++) {
|
|
126
|
+
const a = performance.now()
|
|
127
|
+
built = buildEntityDeltas(st, 'bench', scope)
|
|
128
|
+
chars = 0
|
|
129
|
+
for (const d of built) chars += JSON.stringify(d.data).length
|
|
130
|
+
best = Math.min(best, performance.now() - a)
|
|
131
|
+
}
|
|
132
|
+
return { built, chars, ms: Math.round(best * 10) / 10 }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fullPass = pass()
|
|
136
|
+
const deltas = fullPass.built
|
|
137
|
+
const chars = fullPass.chars
|
|
138
|
+
|
|
139
|
+
const entities =
|
|
140
|
+
st.locations.length + st.items.length + st.equipment.length + (st.assets?.length ?? 0) + (st.persons?.length ?? 0) + st.tasks.length + st.orders.length
|
|
141
|
+
|
|
142
|
+
/* ④ 좁힌 창 — 물품 하나만 건드렸다고 말한 경우. 같은 상태로 재야 비교가 성립한다. */
|
|
143
|
+
const one = new Set<string>([st.items[0]?.epc].filter(Boolean) as string[])
|
|
144
|
+
const scopedPass = pass({ items: one })
|
|
145
|
+
const scoped = scopedPass.built
|
|
146
|
+
|
|
147
|
+
assert.ok(chars > 0 && scopedPass.chars > 0, '문자열을 만들지 않았다면 측정이 성립하지 않는다')
|
|
148
|
+
const ms = (a: number, b: number) => Math.round((b - a) * 10) / 10
|
|
149
|
+
return {
|
|
150
|
+
items: st.items.length,
|
|
151
|
+
entities,
|
|
152
|
+
payloads: deltas.length,
|
|
153
|
+
scopedPayloads: scoped.length,
|
|
154
|
+
snapshotMs: ms(t0, t1),
|
|
155
|
+
buildMs: fullPass.ms,
|
|
156
|
+
broadcastMs: Math.round((ms(t0, t1) + fullPass.ms) * 10) / 10,
|
|
157
|
+
scopedMs: scopedPass.ms,
|
|
158
|
+
fullDeltas: deltas,
|
|
159
|
+
scopedDeltas: scoped
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test('브로드캐스팅 한 번의 비용 — 좁힌 창은 건드린 물품만 만든다', () => {
|
|
164
|
+
/* 예열은 가장 큰 규모로 한 번 버린다 — 작은 규모부터 재면 뒤 측정이 앞의 JIT 이득을 본다. */
|
|
165
|
+
measure(600, 3600)
|
|
166
|
+
|
|
167
|
+
const rows = [measure(600, 60), measure(600, 480), measure(600, 3600)]
|
|
168
|
+
|
|
169
|
+
console.log('[브로드캐스팅 한 번의 비용 · MES 선언본]')
|
|
170
|
+
for (const r of rows) {
|
|
171
|
+
console.log(
|
|
172
|
+
` 물품 ${String(r.items).padStart(5)} · 엔티티 ${String(r.entities).padStart(5)} — payload ${String(r.payloads).padStart(5)}개 · ` +
|
|
173
|
+
`투영 ${String(r.snapshotMs).padStart(6)}ms · 만들기 ${String(r.buildMs).padStart(6)}ms · 합 ${r.broadcastMs}ms` +
|
|
174
|
+
` ▸ 좁힌 창: payload ${String(r.scopedPayloads).padStart(4)}개 · ${r.scopedMs}ms`
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* 측정이 성립했는지 — 규모가 실제로 커져야 비교에 뜻이 있다. */
|
|
179
|
+
assert.ok(rows[0].items > 0, '물품이 쌓여야 한다')
|
|
180
|
+
assert.ok(rows[rows.length - 1].items > rows[0].items * 3, '규모가 실제로 커져야 한다')
|
|
181
|
+
|
|
182
|
+
/*
|
|
183
|
+
* ── 고친 뒤의 단정 (2026-08-21) ──────────────────────────────────────────
|
|
184
|
+
* 전부 만드는 창은 여전히 엔티티 수에 비례한다 — 그 창은 그물이고 주기마다 한 번뿐이다(§그물).
|
|
185
|
+
*
|
|
186
|
+
* **좁힌 창이 만드는 물품 payload 는 말한 것 하나뿐이다.** 이것이 시간과 무관한 단정이다. 기울기로
|
|
187
|
+
* 재지 않는 이유는 이 픽스처가 물품과 함께 오더·작업도 늘리기 때문이다 — 그 축은 좁히지 않으므로
|
|
188
|
+
* (자리 점유·집약과 같은 이유) 기울기에는 남의 몫이 섞인다.
|
|
189
|
+
*
|
|
190
|
+
* 실 현장의 비율은 물품 쪽이 훨씬 무겁다(hatio-us: 물품 2,805 · 오더 192 · 작업 221). 이 픽스처는
|
|
191
|
+
* 오더를 과하게 실은 쪽이므로, 여기서 보이는 절감은 **실 현장보다 작다.**
|
|
192
|
+
*/
|
|
193
|
+
const last = rows[rows.length - 1]
|
|
194
|
+
const itemPayloads = (ds: any[]) => ds.filter(d => d?.data?.entity === 'item').length
|
|
195
|
+
assert.equal(itemPayloads(last.fullDeltas), last.items, '전부 만드는 창은 물품 전부를 만든다')
|
|
196
|
+
assert.equal(itemPayloads(last.scopedDeltas), 1, '좁힌 창은 건드린 물품 하나만 만들어야 한다')
|
|
197
|
+
/* 집약은 좁히지 않는다 — 목록 전체가 한 payload 라서 한 물품이 바뀌면 통째로 새로 만든다. */
|
|
198
|
+
const aggregates = (ds: any[]) => ds.filter(d => String(d.tag).startsWith('__')).length
|
|
199
|
+
assert.equal(aggregates(last.scopedDeltas), aggregates(last.fullDeltas), '집약 뷰는 좁힌 창에서도 그대로 나간다')
|
|
200
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 브로드캐스팅 주기는 **재 본 비용에 맞춘다** (2026-08-21).
|
|
3
|
+
*
|
|
4
|
+
* 한 번의 브로드캐스팅은 상태 크기에 비례한다(실측: 물품 2,400 개 트윈 하나가 4.5ms). 트윈이 스무 개면
|
|
5
|
+
* 200ms 주기에서 90ms 가 브로드캐스팅이고, 그 시간에는 HTTP 도 구독도 서지 못한다. 그래서 브로드캐스팅이 주기의
|
|
6
|
+
* 일정 몫을 넘게 쓰면 주기를 늘리고, 여유가 생기면 되돌린다.
|
|
7
|
+
*
|
|
8
|
+
* 지키는 것:
|
|
9
|
+
* ① 무거우면 늘린다 — 상한까지만.
|
|
10
|
+
* ② 가벼워지면 되돌린다 — 원래 주기 아래로는 내려가지 않는다.
|
|
11
|
+
* ③ 경계에서 늘리고 줄이기를 반복하지 않는다(문턱이 둘이다).
|
|
12
|
+
* ④ **늘렸다는 사실을 센다** — 조용히 늦추면 사람은 화면이 낡은 이유를 알 수 없다.
|
|
13
|
+
*/
|
|
14
|
+
import { test, beforeEach } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { createRequire } from 'node:module'
|
|
17
|
+
|
|
18
|
+
const req = createRequire(import.meta.url)
|
|
19
|
+
const { TwinEngine } = req('../dist-server/engine/twin-engine.js')
|
|
20
|
+
|
|
21
|
+
/** 주기 조정만 부른다 — 실제 브로드캐스팅(pubsub·DB)은 이 판정과 무관하다. */
|
|
22
|
+
const adjust = (flushMs: number) => (TwinEngine as any).adjustBroadcastPeriod(flushMs)
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
TwinEngine.broadcastPeriodMs = TwinEngine.BROADCAST_COALESCE_MS
|
|
26
|
+
TwinEngine.broadcastBackoffs = 0
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('브로드캐스팅이 주기의 몫을 넘게 쓰면 주기를 늘린다 — 상한까지만', () => {
|
|
30
|
+
const base = TwinEngine.BROADCAST_COALESCE_MS
|
|
31
|
+
adjust(base) // 주기를 통째로 쓴 셈
|
|
32
|
+
assert.equal(TwinEngine.broadcastPeriodMs, base * 2)
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < 10; i++) adjust(TwinEngine.broadcastPeriodMs)
|
|
35
|
+
assert.equal(TwinEngine.broadcastPeriodMs, TwinEngine.BROADCAST_MAX_COALESCE_MS, '상한 위로는 올라가지 않는다')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('늘렸다는 사실을 센다 — 조용히 늦추지 않는다', () => {
|
|
39
|
+
adjust(TwinEngine.BROADCAST_COALESCE_MS)
|
|
40
|
+
assert.equal(TwinEngine.broadcastBackoffs, 1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('가벼워지면 되돌린다 — 원래 주기 아래로는 내려가지 않는다', () => {
|
|
44
|
+
adjust(TwinEngine.BROADCAST_COALESCE_MS) // 200 → 400
|
|
45
|
+
assert.ok(TwinEngine.broadcastPeriodMs > TwinEngine.BROADCAST_COALESCE_MS)
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < 10; i++) adjust(0.5)
|
|
48
|
+
assert.equal(TwinEngine.broadcastPeriodMs, TwinEngine.BROADCAST_COALESCE_MS)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('경계에서 늘리고 줄이기를 반복하지 않는다 — 문턱이 둘이다', () => {
|
|
52
|
+
const base = TwinEngine.BROADCAST_COALESCE_MS
|
|
53
|
+
/* 늘리는 문턱(몫)과 되돌리는 문턱(몫의 절반) 사이의 값 — 어느 쪽도 아니어야 한다. */
|
|
54
|
+
const between = base * TwinEngine.BROADCAST_LOAD_RATIO * 0.75
|
|
55
|
+
for (let i = 0; i < 5; i++) adjust(between)
|
|
56
|
+
assert.equal(TwinEngine.broadcastPeriodMs, base, '문턱 사이의 값은 주기를 바꾸지 않는다')
|
|
57
|
+
assert.equal(TwinEngine.broadcastBackoffs, 0)
|
|
58
|
+
})
|
|
@@ -79,7 +79,7 @@ test('설비 에너지 상태는 EPCIS 도 계량도 아닌 자기 어휘로 들
|
|
|
79
79
|
[
|
|
80
80
|
{ equipmentId: 'pv-1', at: '2026-08-15T09:00:00.000Z', generatedKW: 210 },
|
|
81
81
|
{ meterId: 'm-1', kW: 800, at: '2026-08-15T09:00:00.000Z' },
|
|
82
|
-
{ epc: 'urn:epc:id:sgtin:
|
|
82
|
+
{ epc: 'urn:epc:id:sgtin:9521321.012345.3', action: 'OBSERVE', bizStep: 'urn:epcglobal:cbv:bizstep:storing' }
|
|
83
83
|
] as any,
|
|
84
84
|
'tenant-1',
|
|
85
85
|
'2026-08-15T09:00:00.000Z'
|
|
@@ -116,3 +116,38 @@ test('개폐 위치는 표준의 네 값만 받는다 — 과도·불량을 열
|
|
|
116
116
|
assert.equal(bad.rejected.length, 1)
|
|
117
117
|
assert.match(bad.rejected[0].errors.join(' '), /position/)
|
|
118
118
|
})
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* ── 레코드가 말한 시각이 봉투의 시각이 된다 (2026-08-23) ────────────────────
|
|
122
|
+
*
|
|
123
|
+
* 이 경로는 `eventTimePath` 를 주지 않아 **언제나 폴링 시각**을 썼다. 그래서 커넥터가 이미 싣고 있던
|
|
124
|
+
* `eventTime` 이 조용히 무시됐다 — 어디에도 오류가 나지 않고, 커넥터를 붙인 쪽은 그것이 먹는다고
|
|
125
|
+
* 믿고 있었다.
|
|
126
|
+
*
|
|
127
|
+
* 무엇이 걸렸나: **계보의 시각**이다. 4월에 만든 로트가 8월에 만들어진 것으로 저널에 남았다. 그리고
|
|
128
|
+
* 실측 소요를 재는 폴드가 봉투 시각을 쓰므로(`kpi-fold`: `workMs = 완료 − 첫 착수`), 폴링 간격을
|
|
129
|
+
* 작업시간으로 배울 수 있었다 — 못 배우는 것보다 나쁘다.
|
|
130
|
+
*/
|
|
131
|
+
test('레코드가 시각을 말하면 그것이 봉투의 시각이다 — 폴링 시각으로 덮지 않는다', () => {
|
|
132
|
+
const POLLED = '2026-08-23T00:00:00.000Z'
|
|
133
|
+
const SAID = '2026-04-15T07:35:53.000Z'
|
|
134
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
135
|
+
[{ ...epcisRecord, eventTime: SAID }] as any,
|
|
136
|
+
't',
|
|
137
|
+
POLLED
|
|
138
|
+
)
|
|
139
|
+
assert.deepEqual(rejected, [])
|
|
140
|
+
assert.equal(accepted[0].eventTime, SAID, '원본이 말한 시각이어야 한다')
|
|
141
|
+
assert.notEqual(accepted[0].eventTime, POLLED, '폴링 시각이 아니어야 한다')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('레코드가 시각을 말하지 않으면 폴링 시각으로 떨어진다 — 이 한 줄이 기존 경로를 막지 않는다', () => {
|
|
145
|
+
/*
|
|
146
|
+
* 커널의 경로 해석은 없는 값을 **오류로 만들지 않는다**(§`resolve`). 그래서 시각을 말하지 않는
|
|
147
|
+
* 레코드는 예전처럼 기본값을 쓴다. 이 단정이 없으면 위 한 줄이 조용히 대량 거부를 낼 수 있다.
|
|
148
|
+
*/
|
|
149
|
+
const POLLED = '2026-08-23T00:00:00.000Z'
|
|
150
|
+
const { accepted, rejected } = ingestCanonicalRecords([epcisRecord] as any, 't', POLLED)
|
|
151
|
+
assert.deepEqual(rejected, [])
|
|
152
|
+
assert.equal(accepted[0].eventTime, POLLED)
|
|
153
|
+
})
|