@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.
Files changed (143) hide show
  1. package/dist-server/engine/attention-digest.d.ts +52 -0
  2. package/dist-server/engine/attention-digest.js +76 -0
  3. package/dist-server/engine/attention-digest.js.map +1 -0
  4. package/dist-server/engine/board-vocabulary.d.ts +17 -0
  5. package/dist-server/engine/board-vocabulary.js +63 -0
  6. package/dist-server/engine/board-vocabulary.js.map +1 -0
  7. package/dist-server/engine/command-routing.d.ts +33 -0
  8. package/dist-server/engine/command-routing.js +53 -0
  9. package/dist-server/engine/command-routing.js.map +1 -0
  10. package/dist-server/engine/entity-delta.d.ts +13 -6
  11. package/dist-server/engine/entity-delta.js +38 -12
  12. package/dist-server/engine/entity-delta.js.map +1 -1
  13. package/dist-server/engine/index.d.ts +6 -0
  14. package/dist-server/engine/index.js +10 -0
  15. package/dist-server/engine/index.js.map +1 -1
  16. package/dist-server/engine/kpi-baseline.d.ts +78 -0
  17. package/dist-server/engine/kpi-baseline.js +123 -0
  18. package/dist-server/engine/kpi-baseline.js.map +1 -0
  19. package/dist-server/engine/kpi-broadcast.d.ts +4 -0
  20. package/dist-server/engine/kpi-broadcast.js +16 -0
  21. package/dist-server/engine/kpi-broadcast.js.map +1 -0
  22. package/dist-server/engine/kpi-query.d.ts +31 -0
  23. package/dist-server/engine/kpi-query.js +50 -2
  24. package/dist-server/engine/kpi-query.js.map +1 -1
  25. package/dist-server/engine/live-attentions.d.ts +1 -0
  26. package/dist-server/engine/live-attentions.js +7 -1
  27. package/dist-server/engine/live-attentions.js.map +1 -1
  28. package/dist-server/engine/runtime-key.d.ts +15 -0
  29. package/dist-server/engine/runtime-key.js +64 -0
  30. package/dist-server/engine/runtime-key.js.map +1 -0
  31. package/dist-server/engine/state-axes.d.ts +16 -0
  32. package/dist-server/engine/state-axes.js +54 -0
  33. package/dist-server/engine/state-axes.js.map +1 -0
  34. package/dist-server/engine/twin-engine.d.ts +92 -16
  35. package/dist-server/engine/twin-engine.js +335 -77
  36. package/dist-server/engine/twin-engine.js.map +1 -1
  37. package/dist-server/engine/twin-level.d.ts +23 -0
  38. package/dist-server/engine/twin-level.js +52 -0
  39. package/dist-server/engine/twin-level.js.map +1 -0
  40. package/dist-server/engine/warm-start.d.ts +58 -12
  41. package/dist-server/engine/warm-start.js +80 -9
  42. package/dist-server/engine/warm-start.js.map +1 -1
  43. package/dist-server/service/reference/discovery-result.d.ts +34 -0
  44. package/dist-server/service/reference/discovery-result.js +84 -0
  45. package/dist-server/service/reference/discovery-result.js.map +1 -0
  46. package/dist-server/service/reference/ingest-space.d.ts +30 -0
  47. package/dist-server/service/reference/ingest-space.js +63 -0
  48. package/dist-server/service/reference/ingest-space.js.map +1 -0
  49. package/dist-server/service/reference/knob-defaults.d.ts +20 -0
  50. package/dist-server/service/reference/knob-defaults.js +59 -0
  51. package/dist-server/service/reference/knob-defaults.js.map +1 -0
  52. package/dist-server/service/reference/reference-live.js +2 -2
  53. package/dist-server/service/reference/reference-live.js.map +1 -1
  54. package/dist-server/service/reference/reference-master.d.ts +37 -2
  55. package/dist-server/service/reference/reference-master.js +55 -5
  56. package/dist-server/service/reference/reference-master.js.map +1 -1
  57. package/dist-server/service/reference/reference-resolver.d.ts +2 -2
  58. package/dist-server/service/reference/reference-resolver.js +71 -15
  59. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  60. package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
  61. package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
  62. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  63. package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
  64. package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
  65. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  66. package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
  67. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +4 -2
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
  71. package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
  72. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  73. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
  75. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  76. package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
  77. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  78. package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
  79. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  80. package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
  81. package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
  82. package/dist-server/service/twin-structure/twin-structure.js +2 -1
  83. package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
  84. package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
  85. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
  86. package/dist-server/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +6 -6
  88. package/server/engine/attention-digest.ts +102 -0
  89. package/server/engine/board-vocabulary.ts +61 -0
  90. package/server/engine/command-routing.ts +67 -0
  91. package/server/engine/entity-delta.ts +33 -11
  92. package/server/engine/index.ts +10 -0
  93. package/server/engine/kpi-baseline.ts +202 -0
  94. package/server/engine/kpi-broadcast.ts +13 -0
  95. package/server/engine/kpi-query.ts +81 -3
  96. package/server/engine/live-attentions.ts +7 -2
  97. package/server/engine/runtime-key.ts +58 -0
  98. package/server/engine/state-axes.ts +55 -0
  99. package/server/engine/twin-engine.ts +350 -77
  100. package/server/engine/twin-level.ts +48 -0
  101. package/server/engine/warm-start.ts +130 -16
  102. package/server/service/reference/discovery-result.ts +95 -0
  103. package/server/service/reference/ingest-space.ts +70 -0
  104. package/server/service/reference/knob-defaults.ts +59 -0
  105. package/server/service/reference/reference-live.ts +2 -2
  106. package/server/service/reference/reference-master.ts +94 -8
  107. package/server/service/reference/reference-resolver.ts +80 -16
  108. package/server/service/twin-attention/twin-attention-query.ts +43 -6
  109. package/server/service/twin-control/twin-control-mutation.ts +31 -12
  110. package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
  111. package/server/service/twin-instance/twin-instance.ts +6 -2
  112. package/server/service/twin-journal/twin-journal-query.ts +35 -5
  113. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
  114. package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
  115. package/server/service/twin-space/twin-space-resolver.ts +10 -1
  116. package/server/service/twin-state/twin-state-subscription.ts +1 -1
  117. package/server/service/twin-structure/twin-structure.ts +4 -1
  118. package/server/service/twin-target/twin-target-resolver.ts +22 -4
  119. package/test/attention-digest.test.ts +135 -0
  120. package/test/board-vocabulary.test.ts +114 -0
  121. package/test/capability-mapping.test.ts +4 -4
  122. package/test/command-routing.test.ts +61 -0
  123. package/test/discovery-result.test.ts +75 -0
  124. package/test/entity-delta.test.ts +25 -25
  125. package/test/ingest-bench.test.ts +3 -3
  126. package/test/ingest-space.test.ts +50 -0
  127. package/test/knob-defaults.test.ts +72 -0
  128. package/test/kpi-baseline-db.test.ts +214 -0
  129. package/test/kpi-baseline.test.ts +196 -0
  130. package/test/kpi-query-bench.test.ts +128 -0
  131. package/test/live-mirror-parity.test.ts +35 -2
  132. package/test/master-to-twin.test.ts +7 -3
  133. package/test/mutation-gate.test.ts +108 -0
  134. package/test/oee-accumulator.test.ts +65 -1
  135. package/test/registry-key-guard.test.ts +80 -0
  136. package/test/runtime-key.test.ts +66 -0
  137. package/test/scale-twin-bench.test.ts +2 -2
  138. package/test/state-axes.test.ts +74 -0
  139. package/test/streamline-e2e.test.ts +2 -2
  140. package/test/structure-revision-db.test.ts +5 -4
  141. package/test/tenant-registry-db.test.ts +149 -0
  142. package/test/warm-start-seam.test.ts +140 -0
  143. package/test/warm-start.test.ts +138 -4
@@ -0,0 +1,66 @@
1
+ /*
2
+ * 런타임 키 — **트윈의 정체성은 `(도메인, 인스턴스 id)`.**
3
+ *
4
+ * 계기(2026-08-06): 런타임 레지스트리가 `instanceId` 하나로 키를 잡고 있었다. 레퍼런스 경로가 소스
5
+ * 이름을 그대로 id 로 쓰므로(`sap-ewm-1710`) 두 테넌트가 같은 벤더 시스템에 연결하면 같은 id 가 된다 —
6
+ * 그때 남의 커널에 이벤트가 적용되거나, 웜스타트 씨앗이 남의 스냅샷이 됐다.
7
+ *
8
+ * 이 테스트가 지키는 것은 **키가 모호해지지 않는 것** 하나다. 모호한 키는 조용히 틀린다.
9
+ */
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { RUNTIME_KEY_SEP, isOfDomain, parseRuntimeKey, runtimeKey } from '../server/engine/runtime-key.ts'
14
+
15
+ const D1 = '11111111-1111-1111-1111-111111111111'
16
+ const D2 = '22222222-2222-2222-2222-222222222222'
17
+
18
+ test('같은 인스턴스 id 라도 테넌트가 다르면 다른 키다 — 이 작업의 요점', () => {
19
+ assert.notEqual(runtimeKey(D1, 'sap-ewm-1710'), runtimeKey(D2, 'sap-ewm-1710'))
20
+ })
21
+
22
+ test('같은 테넌트·같은 id 는 같은 키다 — 재기동이 자기 자리를 찾는다', () => {
23
+ assert.equal(runtimeKey(D1, 'hatiolab-wms'), runtimeKey(D1, 'hatiolab-wms'))
24
+ })
25
+
26
+ test('되돌리면 원래 둘이 나온다', () => {
27
+ const { domainId, instanceId } = parseRuntimeKey(runtimeKey(D1, 'hatiolab-wms'))
28
+
29
+ assert.equal(domainId, D1)
30
+ assert.equal(instanceId, 'hatiolab-wms')
31
+ })
32
+
33
+ test('인스턴스 id 안의 구분자를 보존한다 — 첫 구분자에서만 자른다', () => {
34
+ /* 레퍼런스 소스 이름에 `:` 가 들어갈 수 있다. 마지막 구분자에서 자르면 id 가 잘려 다른 트윈이 된다. */
35
+ const weird = 'urn:epc:id:sgln:0614141.12345'
36
+ const { domainId, instanceId } = parseRuntimeKey(runtimeKey(D1, weird))
37
+
38
+ assert.equal(domainId, D1)
39
+ assert.equal(instanceId, weird)
40
+ })
41
+
42
+ test('도메인 id 에 구분자가 있으면 던진다 — 조용히 모호한 키를 만들지 않는다', () => {
43
+ /* 조용히 만들면 그게 곧 이 결함의 재발이다(어느 테넌트인지 알 수 없는 키). */
44
+ assert.throws(() => runtimeKey(`a${RUNTIME_KEY_SEP}b`, 'x'), /ambiguous/)
45
+ })
46
+
47
+ test('빈 값은 키가 될 수 없다 — 테넌트 없는 런타임은 없다', () => {
48
+ assert.throws(() => runtimeKey('', 'x'), /domainId is required/)
49
+ assert.throws(() => runtimeKey(D1, ''), /instanceId is required/)
50
+ })
51
+
52
+ test('키가 아닌 것을 되돌리려 하면 던진다', () => {
53
+ for (const bad of ['', 'no-separator', `${RUNTIME_KEY_SEP}leading`, `trailing${RUNTIME_KEY_SEP}`]) {
54
+ assert.throws(() => parseRuntimeKey(bad), /is not a runtime key/, `입력: "${bad}"`)
55
+ }
56
+ })
57
+
58
+ test('도메인으로 걸러낸다 — 접두사가 아니라 경계까지 본다', () => {
59
+ const key = runtimeKey(D1, 'wms')
60
+
61
+ assert.equal(isOfDomain(key, D1), true)
62
+ assert.equal(isOfDomain(key, D2), false)
63
+ /* 도메인 id 의 앞부분만 같은 것에 걸리지 않는다(구분자까지 붙여 본다). */
64
+ assert.equal(isOfDomain(key, D1.slice(0, 8)), false)
65
+ assert.equal(isOfDomain(key, ''), false)
66
+ })
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * 스케일 테스트 트윈 벤치 — 미국 공장(WMS+MES+YMS) 형태를 대규모로 스케일업, 5종 동기 엔티티 전부 부하.
3
3
  * 동기 엔티티 = locations·items(재고)·equipment·tasks·orders (노드/무버뿐이 아님). area 는 계층(건물>층>존).
4
- * 측정(가정 아닌 실측): masterToTwin(구조 생성)·apply(혼합 이벤트 처리량)·snapshot(전체 투영)·buildEntityDeltas(방송)·heap.
4
+ * 측정(가정 아닌 실측): masterToTwin(구조 생성)·apply(혼합 이벤트 처리량)·snapshot(전체 투영)·buildEntityDeltas(방송, 't1')·heap.
5
5
  * 순수 커널+호스트 매핑(shell·DB 무관) → node:test. area 는 공간층(projector State 밖)이라 처리량엔 무영향 — 구조 생성 비용에만.
6
6
  */
7
7
  import { test } from 'node:test'
@@ -89,7 +89,7 @@ for (const [name, tier] of Object.entries(TIERS)) {
89
89
 
90
90
  // 4) 방송 payload(buildEntityDeltas)
91
91
  t = performance.now()
92
- for (let k = 0; k < iters; k++) buildEntityDeltas(s)
92
+ for (let k = 0; k < iters; k++) buildEntityDeltas(s, 't1')
93
93
  const tDelta = (performance.now() - t) / iters
94
94
  console.log(` [${name}] buildEntityDeltas: ${tDelta.toFixed(2)}ms/call`)
95
95
  console.log(` [${name}] heap: ${MB(process.memoryUsage().heapUsed)}MB`)
@@ -0,0 +1,74 @@
1
+ /*
2
+ * ★ 상태 축 고르기 — 리프레시가 통째로여서 무겁던 것.
3
+ *
4
+ * 지도는 구독 신호를 받고 상태를 다시 읽는다(신호는 가볍고 리프레시는 250ms 로 합쳐진다 — 설계대로).
5
+ * 문제는 **읽는 양**이었다: 실 저널로 재 보니 트윈 하나가 최대 710KB, 같은 공간에 셋이면 약 2MB.
6
+ * 그런데 지도가 쓰는 축은 `locations`·`attentions` 둘뿐이다.
7
+ *
8
+ * 여기서 지키는 것: 말하지 않으면 자르지 않는다(기존 호출부 무영향), 오타로 화면이 비지 않는다.
9
+ */
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { pickAxes, STATE_AXES } from '../server/engine/state-axes.ts'
14
+
15
+ const STATE = {
16
+ revision: 12,
17
+ nowTime: '2026-08-05T00:00:00.000Z',
18
+ simClockMs: 1000,
19
+ locations: [{ id: 'dock' }],
20
+ items: [{ epc: 'a' }, { epc: 'b' }],
21
+ equipment: [{ id: 'fk-1' }],
22
+ tasks: [{ id: 't1' }],
23
+ orders: [{ id: 'o1' }],
24
+ attentions: [{ id: 'bottleneck:dock' }],
25
+ acked: ['bottleneck:dock']
26
+ }
27
+
28
+ test('고른 축만 남는다 — 지도가 쓰는 둘', () => {
29
+ const { state, unknown } = pickAxes(STATE, ['locations', 'attentions'])
30
+ assert.deepEqual(unknown, [])
31
+ assert.deepEqual(Object.keys(state).sort(), ['attentions', 'locations', 'nowTime', 'revision', 'simClockMs'])
32
+ assert.equal(state.items, undefined, '쓰지 않는 축은 오지 않는다')
33
+ })
34
+
35
+ test('축을 말하지 않으면 자르지 않는다 — 기존 호출부가 그대로 동작해야 한다', () => {
36
+ assert.equal(pickAxes(STATE, undefined).state, STATE)
37
+ assert.equal(pickAxes(STATE, []).state, STATE)
38
+ assert.equal(pickAxes(STATE, null).state, STATE)
39
+ })
40
+
41
+ test('시각과 리비전은 늘 남는다 — 무엇을 언제 받았는지 알아야 한다', () => {
42
+ const { state } = pickAxes(STATE, ['locations'])
43
+ assert.equal(state.revision, 12)
44
+ assert.equal(state.nowTime, STATE.nowTime)
45
+ })
46
+
47
+ test('모르는 축은 무시하되 그 사실을 돌려준다 — 오타로 화면이 비지 않게', () => {
48
+ const { state, unknown } = pickAxes(STATE, ['locations', 'attension'])
49
+ assert.deepEqual(unknown, ['attension'])
50
+ assert.ok(state.locations, '아는 축은 그대로 온다')
51
+ })
52
+
53
+ test('전부 오타면 자르지 않는다 — 빈 화면보다 통째가 낫다', () => {
54
+ const { state, unknown } = pickAxes(STATE, ['nope', 'alsonope'])
55
+ assert.equal(state, STATE)
56
+ assert.deepEqual(unknown, ['nope', 'alsonope'])
57
+ })
58
+
59
+ test('없는 축을 요청해도 키를 만들지 않는다 — 없는 것을 빈 배열로 위장하지 않는다', () => {
60
+ const { state } = pickAxes({ revision: 1, locations: [] }, ['locations', 'persons'])
61
+ assert.deepEqual(Object.keys(state).sort(), ['locations', 'revision'])
62
+ assert.equal('persons' in state, false)
63
+ })
64
+
65
+ test('상태가 아니면 그대로 돌려준다', () => {
66
+ assert.equal(pickAxes(null, ['locations']).state, null)
67
+ assert.equal(pickAxes(undefined, ['locations']).state, undefined)
68
+ })
69
+
70
+ test('축 목록이 상태의 축과 어긋나지 않는다 — 이름이 바뀌면 여기서 걸린다', () => {
71
+ for (const a of ['locations', 'items', 'equipment', 'attentions', 'acked']) {
72
+ assert.ok((STATE_AXES as readonly string[]).includes(a), a)
73
+ }
74
+ })
@@ -48,7 +48,7 @@ test('★ 캡스톤: master → masterToTwin → 커널 로드 → tick → 라
48
48
 
49
49
  // 3) 스냅샷 → 라이브 payload(보드 컴포넌트가 구독할 data(tag))
50
50
  const state = snapshotState(kernel)
51
- const deltas = buildEntityDeltas(state)
51
+ const deltas = buildEntityDeltas(state, 't1')
52
52
  const tags = new Set(deltas.map(d => d.tag))
53
53
 
54
54
  // 4) 보드의 모든 노드·무버가 payload 에 도달(커널→보드 결합 성립)
@@ -70,7 +70,7 @@ test('캡스톤: 결정성 — 같은 board+tick 은 같은 payload 시그니처
70
70
  kernel.loadBoard(board)
71
71
  const runtime = new TwinRuntime(kernel)
72
72
  for (let i = 0; i < 8; i++) runtime.tick(1000)
73
- return JSON.stringify(buildEntityDeltas(snapshotState(kernel)))
73
+ return JSON.stringify(buildEntityDeltas(snapshotState(kernel), 't1'))
74
74
  }
75
75
  assert.equal(run(), run(), '동일 입력 → 동일 payload(결정적)')
76
76
  })
@@ -89,7 +89,8 @@ beforeEach(async () => {
89
89
  for (const target of [TwinEvent, TwinStructure, TwinInstance]) await ds.query(`DELETE FROM "${ds.getMetadata(target).tableName}"`)
90
90
  /* 엔진의 리비전 캐시도 비운다 — 스펙 간에 남으면 다음 스펙이 옛 번호를 찍는다. */
91
91
  ;(TwinEngine as any).structureRevCache = {}
92
- delete (TwinEngine as any).recovered[INSTANCE]
92
+ /* 키가 `(domainId, instanceId)` 로 바뀌었다 — id 만으로 지우면 아무것도 지워지지 않는다. */
93
+ delete (TwinEngine as any).recovered[`${domainId}:${INSTANCE}`]
93
94
  })
94
95
 
95
96
  test('같은 구조로 다시 프로비저닝하면 리비전이 늘지 않는다 — 부팅마다 늘면 이력이 잘게 쪼개진다', async () => {
@@ -277,7 +278,7 @@ test('sim-world 로 다시 띄워도 저널이 살아남는다 — 선언이 지
277
278
  const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
278
279
  assert.equal(kept, first.length, 'sim-world 는 이어지는 현실이다 — 지우면 선언이 거짓이 된다')
279
280
  } finally {
280
- await TwinEngine.stop(INSTANCE)
281
+ await TwinEngine.stop(domainId, INSTANCE)
281
282
  }
282
283
  })
283
284
 
@@ -295,7 +296,7 @@ test('★ 재기동 후 새 이벤트는 마지막 번호 뒤에 온다 — 겹
295
296
  `이어 세지 않았다 — 저널 마지막 ${first.length}, 커널 ${kernel.getSnapshot().revision}`
296
297
  )
297
298
  } finally {
298
- await TwinEngine.stop(INSTANCE)
299
+ await TwinEngine.stop(domainId, INSTANCE)
299
300
  }
300
301
  })
301
302
 
@@ -305,6 +306,6 @@ test('저널이 비어 있으면 이어 셀 것이 없다 — 새로 시작하
305
306
  try {
306
307
  assert.equal((rt.kernel as any).getSnapshot().revision, 0)
307
308
  } finally {
308
- await TwinEngine.stop(INSTANCE)
309
+ await TwinEngine.stop(domainId, INSTANCE)
309
310
  }
310
311
  })
@@ -0,0 +1,149 @@
1
+ /*
2
+ * ★ 두 테넌트가 **같은 인스턴스 id** 로 각자 돈다 — 실 DB로 경계를 넘어 본다.
3
+ *
4
+ * ── 계기 (2026-08-06 테넌트 격리 전수 확인) ──────────────────────────────────
5
+ * 런타임 레지스트리가 `instanceId` 하나로 키를 잡고 있었다. 그런데 DB 유일성은 `[domain, instanceId]`
6
+ * 이고 트윈의 정체성도 그쪽이다. **충돌은 이론이 아니다** — 레퍼런스 경로가 `instanceId` 로 레퍼런스
7
+ * 소스 이름(`sap-ewm-1710`)을 쓰고, 벤더 시스템 이름은 테넌트가 공유하는 어휘다.
8
+ *
9
+ * 그때 무엇이 틀렸나: `start`/`startFromRegistry` 가 **남의 런타임을 돌려주고**, `ingestLive` 는 도메인을
10
+ * 확인하지 않아 남의 커널에 이벤트를 적용할 수 있었고, 목록의 `running` 이 남의 런타임을 봤다.
11
+ *
12
+ * 이 결함은 **유닛으로는 안 잡힌다** — 두 테넌트가 실제로 등록되고 각자 기동해야 드러난다. 그래서
13
+ * 실 엔티티 sqlite 스키마 + 빌드 산출물로 경계를 넘는다(`structure-revision-db.test.ts` 와 같은 하네스).
14
+ * **소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌려야 반영된다.**
15
+ */
16
+ import { test, before, after, afterEach } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { createRequire } from 'node:module'
19
+ import type { DataSource } from 'typeorm'
20
+
21
+ const req = createRequire(import.meta.url)
22
+ const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
23
+ const { entities: authEntities } = req('@things-factory/auth-base')
24
+ const { entities: twinEntities } = req('../dist-server/service/index.js')
25
+ const { TwinEngine } = req('../dist-server/engine/index.js')
26
+ const { DataSource: TypeOrmDataSource } = req('typeorm')
27
+
28
+ let ds: DataSource
29
+ let alpha: string
30
+ let beta: string
31
+
32
+ /** 두 테넌트가 같은 벤더 시스템을 연결했을 때 실제로 생기는 id. */
33
+ const SHARED_ID = 'sap-ewm-1710'
34
+
35
+ const board = (): any => ({
36
+ spaceId: 'shared-space',
37
+ utcOffsetMinutes: 0,
38
+ locations: [
39
+ { id: 'dock-recv', type: 'dock', capacity: 100 },
40
+ { id: 'bin-1', type: 'storage', capacity: 20 }
41
+ ],
42
+ equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv' }]
43
+ })
44
+
45
+ before(async () => {
46
+ ds = new TypeOrmDataSource({
47
+ type: 'sqlite',
48
+ database: ':memory:',
49
+ synchronize: true,
50
+ dropSchema: true,
51
+ namingStrategy: new NamingStrategy(),
52
+ entities: [...shellEntities, ...authEntities, ...twinEntities],
53
+ logging: false
54
+ })
55
+ await ds.initialize()
56
+ addDataSource('default', ds)
57
+
58
+ const a = await ds.getRepository(Domain).save({ name: 'tenant-alpha', subdomain: 'tenant-alpha' } as any)
59
+ const b = await ds.getRepository(Domain).save({ name: 'tenant-beta', subdomain: 'tenant-beta' } as any)
60
+ alpha = a.id
61
+ beta = b.id
62
+
63
+ /* 두 테넌트가 **같은 id** 로 각자 프로비저닝한다 — DB 유일성이 `[domain, instanceId]` 라 허용된다. */
64
+ await TwinEngine.provision(alpha, SHARED_ID, 'wms', board())
65
+ await TwinEngine.provision(beta, SHARED_ID, 'wms', board())
66
+ })
67
+
68
+ /**
69
+ * **스펙마다 반드시 멈춘다.** 남기면 두 가지가 따라온다: 틱 타이머 때문에 러너가 끝나지 않고, 틱이
70
+ * 뒤늦게 던지는 DB 쓰기가 teardown 이후에 도착해 `SQLITE_MISUSE` 로 스위트를 오염시킨다.
71
+ */
72
+ afterEach(async () => {
73
+ await TwinEngine.stop(alpha, SHARED_ID).catch(() => {})
74
+ await TwinEngine.stop(beta, SHARED_ID).catch(() => {})
75
+ })
76
+
77
+ after(async () => {
78
+ /* 정지 직후에도 진행 중인 쓰기가 있을 수 있다 — 커넥션을 닫기 전에 가라앉힌다. */
79
+ await new Promise(resolve => setTimeout(resolve, 120))
80
+ await ds?.destroy()
81
+ })
82
+
83
+ test('두 테넌트가 같은 id 로 프로비저닝된다 — 서로를 막지 않는다', async () => {
84
+ /* 예전에는 한쪽이 **떠 있으면** 다른 쪽 재프로비전이 "instance is running" 으로 막혔다(존재 노출). */
85
+ const listA = await TwinEngine.list(alpha)
86
+ const listB = await TwinEngine.list(beta)
87
+
88
+ assert.equal(listA.filter((x: any) => x.instanceId === SHARED_ID).length, 1)
89
+ assert.equal(listB.filter((x: any) => x.instanceId === SHARED_ID).length, 1)
90
+ })
91
+
92
+ test('한쪽만 기동하면 다른 쪽은 **돌지 않는 것으로** 보인다 — 이 회귀가 핵심', async () => {
93
+ await TwinEngine.startFromRegistry(alpha, SHARED_ID)
94
+
95
+ assert.equal(TwinEngine.owns(alpha, SHARED_ID), true, 'alpha 는 자기 트윈을 갖는다')
96
+ assert.equal(TwinEngine.owns(beta, SHARED_ID), false, 'beta 는 기동하지 않았다')
97
+
98
+ /* 목록의 running 이 남의 런타임을 보면 안 된다 — 예전에는 beta 도 running: true 였다. */
99
+ const rowA = (await TwinEngine.list(alpha)).find((x: any) => x.instanceId === SHARED_ID)
100
+ const rowB = (await TwinEngine.list(beta)).find((x: any) => x.instanceId === SHARED_ID)
101
+ assert.equal(rowA.running, true)
102
+ assert.equal(rowB.running, false)
103
+ })
104
+
105
+ test('둘 다 기동하면 **서로 다른 런타임**이다 — 남의 것을 돌려받지 않는다', async () => {
106
+ const rtA = await TwinEngine.startFromRegistry(alpha, SHARED_ID)
107
+ const rtB = await TwinEngine.startFromRegistry(beta, SHARED_ID)
108
+
109
+ /* 예전에는 두 번째 호출이 첫 번째의 런타임을 그대로 돌려줬다(조기 반환이 id 로만 찾았다). */
110
+ assert.notEqual(rtA, rtB, '같은 객체를 돌려주면 두 테넌트가 한 커널을 공유한다')
111
+ assert.notEqual(TwinEngine.kernel(alpha, SHARED_ID), TwinEngine.kernel(beta, SHARED_ID))
112
+ assert.equal(rtA.domainId, alpha)
113
+ assert.equal(rtB.domainId, beta)
114
+ })
115
+
116
+ test('접근자가 테넌트별로 답한다 — 커널·스냅샷·런타임', async () => {
117
+ await TwinEngine.startFromRegistry(alpha, SHARED_ID)
118
+ await TwinEngine.startFromRegistry(beta, SHARED_ID)
119
+
120
+ for (const [who, other] of [
121
+ [alpha, beta],
122
+ [beta, alpha]
123
+ ]) {
124
+ assert.ok(TwinEngine.kernel(who, SHARED_ID), '자기 커널은 있다')
125
+ assert.notEqual(TwinEngine.kernel(who, SHARED_ID), TwinEngine.kernel(other, SHARED_ID))
126
+ assert.notEqual(TwinEngine.runtime(who, SHARED_ID), TwinEngine.runtime(other, SHARED_ID))
127
+ }
128
+ })
129
+
130
+ test('한쪽을 정지해도 다른 쪽은 계속 돈다 — 남의 런타임을 지우지 않는다', async () => {
131
+ await TwinEngine.startFromRegistry(alpha, SHARED_ID)
132
+ await TwinEngine.startFromRegistry(beta, SHARED_ID)
133
+
134
+ await TwinEngine.stop(alpha, SHARED_ID)
135
+
136
+ assert.equal(TwinEngine.owns(alpha, SHARED_ID), false, '정지한 쪽은 사라진다')
137
+ assert.equal(TwinEngine.owns(beta, SHARED_ID), true, '남의 트윈은 그대로다')
138
+ assert.ok(TwinEngine.kernel(beta, SHARED_ID), 'beta 커널이 살아 있다')
139
+ })
140
+
141
+ test('없는 테넌트로 물으면 아무것도 주지 않는다 — 추측한 id 로 새지 않는다', async () => {
142
+ await TwinEngine.startFromRegistry(alpha, SHARED_ID)
143
+ const stranger = '99999999-9999-9999-9999-999999999999'
144
+
145
+ assert.equal(TwinEngine.owns(stranger, SHARED_ID), false)
146
+ assert.equal(TwinEngine.kernel(stranger, SHARED_ID), undefined)
147
+ assert.equal(TwinEngine.runtime(stranger, SHARED_ID), undefined)
148
+ assert.equal(TwinEngine.metrics(stranger, SHARED_ID), null)
149
+ })
@@ -0,0 +1,140 @@
1
+ /*
2
+ * ★ 웜스타트 이음매 검증 — 판정이 만든 씨앗이 **실제 커널에 심기는지**.
3
+ *
4
+ * ── 왜 따로 있나 ────────────────────────────────────────────────────────────
5
+ * `warm-start.test.ts` 는 순수 판정만 본다("무엇을 씨앗에 담기로 했나"). 그것만으로는 부족했다:
6
+ * 재기동 후 주문이 사라진 결함은 판정과 커널 **사이**에 있었기 때문이다. 판정은 오더를 버렸고,
7
+ * 커널은 받을 준비가 되어 있었다. 양쪽 테스트가 각각 초록이어도 이음매는 끊겨 있었다.
8
+ *
9
+ * 특히 이 테스트가 겨누는 것은 **`snap.orders` 경로**다. 커널은 오더를 두 가지로 받는다 —
10
+ * 두 번째 인자(`OrderStatusDelta[]`)와 스냅샷 안(`snap.orders`). 커널 테스트는 두 번째 인자를
11
+ * 쓰고, 호스트 웜스타트는 스냅샷 안을 쓴다. 즉 **호스트가 의존하는 쪽이 검증되지 않은 상태**였다.
12
+ *
13
+ * ── 무엇을 단언하나 ────────────────────────────────────────────────────────
14
+ * 스냅샷의 오더가 남은 데맨드(requested − fulfilled)로 커널에 서고, 이행이 끝난 오더는 서지 않고,
15
+ * 원값이 없는 오더는 조용히 서지 않는다는 것. 숫자를 지어내지 않는다는 규칙이 이음매 끝까지 지켜지는가.
16
+ */
17
+ import { test } from 'node:test'
18
+ import assert from 'node:assert/strict'
19
+
20
+ import { WmsKernel } from '@operato/twin-kernel'
21
+
22
+ import { planWarmStart } from '../server/engine/warm-start.ts'
23
+ import { withLiveAttentions } from '../server/engine/live-attentions.ts'
24
+
25
+ const GTIN = 'urn:epc:idpat:sgtin:0614141.012345.*'
26
+
27
+ /*
28
+ * 보드 — WMS 출고 흐름(피킹 → 스테이징 → 출하)이 실제로 굴러갈 최소 구성.
29
+ * `staging` 을 빼고 짜 봤더니 오더가 서기만 하고 진행이 0 이었다. 이음매가 아니라 픽스처 문제였다 —
30
+ * 이 테스트가 흐름 요건까지 시험하려는 것이 아니므로 커널 테스트와 같은 네 자리를 쓴다.
31
+ */
32
+ const board = () => ({
33
+ locations: [
34
+ { id: 'dock-recv', type: 'dock', capacity: 100 },
35
+ { id: 'bin-1', type: 'storage', capacity: 200 },
36
+ { id: 'staging', type: 'staging', capacity: 100 },
37
+ { id: 'dock-ship', type: 'dock-ship', capacity: 100 }
38
+ ],
39
+ equipment: [
40
+ { id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv' },
41
+ { id: 'fk-2', kind: 'forklift', homeLocation: 'dock-recv' }
42
+ ]
43
+ })
44
+
45
+ /** 관측 스냅샷 흉내가 아니라, 스냅샷이 실제로 들고 오는 모양(일곱 축 중 필요한 것). */
46
+ const observed = (orders: unknown[]) => ({
47
+ locations: board().locations.map(n => ({ ...n, occupancy: 0, status: 'idle' })),
48
+ items: Array.from({ length: 20 }, (_, i) => ({
49
+ epc: `urn:epc:id:sgtin:0614141.012345.${i}`,
50
+ location: 'bin-1',
51
+ gtin: GTIN,
52
+ qty: 1
53
+ })),
54
+ equipment: board().equipment.map(m => ({ ...m, location: 'dock-recv', status: 'idle' })),
55
+ tasks: [],
56
+ orders
57
+ })
58
+
59
+ /** 판정 → 주입. 호스트 `warmStart` 가 하는 일과 같은 순서로 잇는다. */
60
+ function seedKernel(state: ReturnType<typeof observed>) {
61
+ const plan = planWarmStart(state, undefined, true)
62
+ assert.equal(plan.action, 'hydrate', '심기로 판정해야 한다')
63
+ if (plan.action !== 'hydrate') throw new Error('unreachable')
64
+ const k = new WmsKernel('t')
65
+ k.loadBoard(board() as any)
66
+ ;(k as any).hydrateObserved(plan.seed)
67
+ return { k, plan }
68
+ }
69
+
70
+ test('스냅샷 안의 오더가 커널에 선다 — 남은 데맨드로', () => {
71
+ const { k, plan } = seedKernel(
72
+ observed([{ id: 'SO-1', kind: 'outbound', status: 'in-progress', requested: 20, fulfilled: 5, lines: [{ gtin: GTIN, requested: 20, fulfilled: 5 }] }])
73
+ )
74
+ assert.equal(plan.orderCount, 1)
75
+ const o = (k as any).orders.get('SO-1')
76
+ assert.ok(o, '재기동 후 진행 중 주문이 커널에 있어야 한다 — 사라지던 결함의 회귀 방지')
77
+ assert.equal(o.requested, 15, '남은 데맨드 15(20−5)로 서야 한다 — 원값이 아니라 남은 양이다')
78
+ })
79
+
80
+ test('재고가 있으면 복원된 오더가 실제로 이행된다 — 서 있기만 하면 의미가 없다', () => {
81
+ const { k } = seedKernel(
82
+ observed([{ id: 'SO-1', kind: 'outbound', status: 'in-progress', requested: 8, fulfilled: 0, lines: [{ gtin: GTIN, requested: 8, fulfilled: 0 }] }])
83
+ )
84
+ const fc = (k as any).fork()
85
+ for (let t = 0; t < 1200; t++) fc.tick(1000)
86
+ const o = fc.getSnapshot().orders.find((x: any) => x.id === 'SO-1')
87
+ assert.ok(o, '예측에도 오더가 있어야 한다')
88
+ assert.ok(o.progress > 0, `복원된 오더가 진행돼야 한다(progress=${o.progress})`)
89
+ })
90
+
91
+ test('이미 이행된 오더는 서지 않는다 — 다 끝난 일을 다시 계획하지 않는다', () => {
92
+ const { k, plan } = seedKernel(
93
+ observed([{ id: 'SO-DONE', kind: 'outbound', status: 'completed', requested: 10, fulfilled: 10, lines: [{ gtin: GTIN, requested: 10, fulfilled: 10 }] }])
94
+ )
95
+ assert.equal(plan.orderCount, 1, '판정은 원값이 있으므로 넘긴다')
96
+ assert.equal((k as any).orders.get('SO-DONE'), undefined, '커널이 남은 양 0 으로 보고 세우지 않는다')
97
+ })
98
+
99
+ test('원값이 없는 오더는 커널에 서지 않고, 몇 건인지 남는다', () => {
100
+ const state = observed([{ id: 'SO-P', kind: 'outbound', status: 'in-progress', progress: 0.5 }])
101
+ const plan = planWarmStart(state, undefined, true)
102
+ assert.equal(plan.action, 'hydrate')
103
+ if (plan.action !== 'hydrate') return
104
+ assert.equal(plan.orderCount, 0)
105
+ assert.equal(plan.ordersWithoutDemand, 1, '뺀 사실이 숫자로 남아야 로그가 말할 수 있다')
106
+
107
+ const k = new WmsKernel('t')
108
+ k.loadBoard(board() as any)
109
+ ;(k as any).hydrateObserved(plan.seed)
110
+ assert.equal((k as any).orders.size, 0, 'progress 만으로는 남은 양을 알 수 없으므로 지어내지 않는다')
111
+ })
112
+
113
+ test('재고도 함께 살아난다 — 오더를 넣다가 기존 복원을 깨지 않았는지', () => {
114
+ const { k } = seedKernel(observed([{ id: 'SO-1', requested: 5, fulfilled: 0, kind: 'outbound', status: 'in-progress' }]))
115
+ const snap = (k as any).getSnapshot()
116
+ assert.equal(snap.items.length, 20, '재고 20건이 그대로 복원돼야 한다')
117
+ assert.equal(snap.equipment.length, 2)
118
+ })
119
+
120
+ test('라이브 상태의 확인 처리가 신호에 반영된다 — 라이브에서는 아예 안 먹던 것', () => {
121
+ /*
122
+ * `withLiveAttentions` 가 확인 목록을 넘기지 않아, 라이브(미러)에서는 사람이 확인해도 신호가
123
+ * 계속 활성으로 나갔다. 저널에서 되살린 확인 처리가 여기까지 닿는지 본다.
124
+ */
125
+ const state = {
126
+ locations: [{ id: 'bin-tight', type: 'storage', capacity: 1, occupancy: 1 }],
127
+ equipment: [],
128
+ orders: [],
129
+ tasks: [],
130
+ acked: [] as string[]
131
+ }
132
+ const before = withLiveAttentions(state).attentions ?? []
133
+ const signal = before[0]
134
+ assert.ok(signal, '픽스처 전제 — 병목 신호가 서야 한다')
135
+ /* 확인 전에는 `state` 를 채우지 않는다(확인된 것만 표시한다) — 없음과 'acknowledged' 를 가른다. */
136
+ assert.notEqual(signal.state, 'acknowledged', '확인하지 않았는데 확인된 것으로 나오면 안 된다')
137
+
138
+ const after = withLiveAttentions({ ...state, acked: [signal.id] }).attentions ?? []
139
+ assert.equal(after.find((a: any) => a.id === signal.id)?.state, 'acknowledged', '확인 처리가 라이브 신호에 반영돼야 한다')
140
+ })