@things-factory/headless-twin 10.0.5 → 10.0.6

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 (43) hide show
  1. package/dist-server/engine/kpi-fold.js +24 -49
  2. package/dist-server/engine/kpi-fold.js.map +1 -1
  3. package/dist-server/engine/kpi-query.d.ts +7 -0
  4. package/dist-server/engine/kpi-query.js +11 -0
  5. package/dist-server/engine/kpi-query.js.map +1 -1
  6. package/dist-server/engine/twin-engine.d.ts +29 -2
  7. package/dist-server/engine/twin-engine.js +78 -21
  8. package/dist-server/engine/twin-engine.js.map +1 -1
  9. package/dist-server/engine/warm-start.d.ts +39 -0
  10. package/dist-server/engine/warm-start.js +37 -0
  11. package/dist-server/engine/warm-start.js.map +1 -0
  12. package/dist-server/index.js +8 -0
  13. package/dist-server/index.js.map +1 -1
  14. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  15. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  16. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  17. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  18. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  19. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  20. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  21. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  22. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  23. package/dist-server/service/twin-event/twin-event.d.ts +5 -0
  24. package/dist-server/service/twin-event/twin-event.js +45 -0
  25. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  26. package/dist-server/service/twin-journal/twin-journal-query.d.ts +19 -0
  27. package/dist-server/service/twin-journal/twin-journal-query.js +74 -0
  28. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  29. package/dist-server/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +6 -6
  31. package/server/engine/kpi-fold.ts +23 -52
  32. package/server/engine/kpi-query.ts +11 -0
  33. package/server/engine/twin-engine.ts +95 -28
  34. package/server/engine/warm-start.ts +53 -0
  35. package/server/index.ts +9 -0
  36. package/server/service/twin-event/backfill-keys.ts +72 -0
  37. package/server/service/twin-event/twin-event-keys.ts +102 -0
  38. package/server/service/twin-event/twin-event-type.ts +27 -0
  39. package/server/service/twin-event/twin-event.ts +48 -0
  40. package/server/service/twin-journal/twin-journal-query.ts +79 -3
  41. package/test/kpi-fold.test.ts +22 -0
  42. package/test/twin-event-keys.test.ts +108 -0
  43. package/test/warm-start.test.ts +78 -0
@@ -10,8 +10,28 @@ import { Domain, ScalarObject } from '@things-factory/shell'
10
10
  * payload 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통, DB-specific JSON 타입 금지).
11
11
  * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
12
12
  */
13
+ /*
14
+ * ── 인덱스 설계 (2026-07-31) ────────────────────────────────────────────────
15
+ * 이 표는 **인제스트 경로의 뜨거운 append-only 테이블**이다. 인덱스 하나하나가 쓰기 증폭이므로
16
+ * "있으면 좋을" 인덱스를 붙이지 않는다. 실제 질의 패턴에 대응하는 것만 둔다.
17
+ *
18
+ * ix_0 (domain, instanceId, revision) 원장 기본 정렬·커서 페이징·replay(ASC 주사)
19
+ * ix_1 (domain, instanceId, eventTime) 시각 커서(untilTime)·시간창 KPI — 거의 모든 조회가 탄다
20
+ * ix_2 (domain, instanceId, eventType, revision) 타입 필터 + 정렬 동시 충족(스케줄 화면 task/equipment)
21
+ * ix_3 (domain, instanceId, epc) "이 물건의 이력" — Entity360 의 본질 질문
22
+ * ix_4 (domain, instanceId, orderId) "이 오더가 어디까지 갔나"
23
+ *
24
+ * bizStep·locationId·moverId 는 컬럼만 두고 인덱스는 두지 않는다 — 한 트윈 안에서 카디널리티가
25
+ * 낮아(업무단계 몇 개, 위치 수백, 설비 수십) (domain,instanceId) 로 이미 좁혀진 뒤의 잔여 필터로
26
+ * 충분하고, 뜨거운 표에 인덱스를 더 얹을 값어치가 없다. 저널 하나가 아주 커져서 이 축들의 조회가
27
+ * 느려지면 그때 측정을 근거로 인덱스를 추가할 일이지, 지레 얹어 쓰기를 무겁게 할 일은 아니다.
28
+ */
13
29
  @Entity()
14
30
  @Index('ix_twin_event_0', (e: TwinEvent) => [e.domain, e.instanceId, e.revision], { unique: false })
31
+ @Index('ix_twin_event_1', (e: TwinEvent) => [e.domain, e.instanceId, e.eventTime], { unique: false })
32
+ @Index('ix_twin_event_2', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.revision], { unique: false })
33
+ @Index('ix_twin_event_3', (e: TwinEvent) => [e.domain, e.instanceId, e.epc], { unique: false })
34
+ @Index('ix_twin_event_4', (e: TwinEvent) => [e.domain, e.instanceId, e.orderId], { unique: false })
15
35
  @ObjectType({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' })
16
36
  export class TwinEvent {
17
37
  @PrimaryGeneratedColumn('uuid')
@@ -45,6 +65,34 @@ export class TwinEvent {
45
65
  @Field({ nullable: true, description: 'Simulation event time (ISO 8601).' })
46
66
  eventTime?: string
47
67
 
68
+ /*
69
+ * ── 승격된 검색 축 ────────────────────────────────────────────────────────
70
+ * payload 안에 있던 값을 인제스트 시점에 꺼내 실컬럼으로 둔다(`twin-event-keys.ts` 가 단독 소유).
71
+ * payload 가 여전히 정본이고 이것들은 **파생 색인**이다 — 새로운 진실이 아니라 찾을 수 있게 하는 장치.
72
+ * simple-json(TEXT) 안의 값은 5개 DB 드라이버 공통으로 거를 방법이 없어서(멀티DB 호환 규칙상
73
+ * DB별 JSON 연산자 금지) 승격 외의 선택지가 없다.
74
+ * 길이 상한 255 는 GS1 식별자 규격 대비 충분하며, 넘치는 값은 조용히 잘리지 않고 경고를 남긴다.
75
+ */
76
+ @Column({ length: 255, nullable: true })
77
+ @Field({ nullable: true, description: 'Business step (CBV bizStep tail), promoted from the payload for indexed filtering.' })
78
+ bizStep?: string
79
+
80
+ @Column({ length: 255, nullable: true })
81
+ @Field({ nullable: true, description: 'Item identifier (EPC / EPC class / parent id), promoted from the payload for indexed lookup of one item history.' })
82
+ epc?: string
83
+
84
+ @Column({ length: 255, nullable: true })
85
+ @Field({ nullable: true, description: 'Business transaction identifier (PO / SO), promoted from the payload for indexed lookup of one order history.' })
86
+ orderId?: string
87
+
88
+ @Column({ length: 255, nullable: true })
89
+ @Field({ nullable: true, description: 'Location identifier (read point, business location, or the plain location an operational delta carries), promoted from the payload for indexed filtering.' })
90
+ locationId?: string
91
+
92
+ @Column({ length: 255, nullable: true })
93
+ @Field({ nullable: true, description: 'Equipment or mover identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server.' })
94
+ moverId?: string
95
+
48
96
  @Column({ type: 'simple-json', nullable: true })
49
97
  @Field(type => ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' })
50
98
  payload?: any
@@ -1,11 +1,12 @@
1
1
  import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'
2
- import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
2
+ import { Arg, Args, Ctx, Int, Query, Resolver } from 'type-graphql'
3
3
 
4
- import { getRepository, ScalarObject } from '@things-factory/shell'
4
+ import { buildNextCursor, cursorSortings, getQueryBuilderFromListParams, getRepository, ListParam, ScalarObject } from '@things-factory/shell'
5
5
 
6
6
  import { TwinEvent } from '../twin-event/twin-event.js'
7
+ import { TwinEventList } from '../twin-event/twin-event-type.js'
7
8
  import { TwinEngine } from '../../engine/index.js'
8
- import { computeTwinKpi } from '../../engine/kpi-query.js'
9
+ import { computeTwinKpi, resolveTwinTargets } from '../../engine/kpi-query.js'
9
10
 
10
11
  /*
11
12
  * 저널 읽기 채널 — 상향 query.
@@ -39,6 +40,81 @@ export class TwinJournalQuery {
39
40
  return getRepository(TwinEvent).find({ where, order: { revision: 'DESC' }, take: limit ?? 200 })
40
41
  }
41
42
 
43
+ /**
44
+ * 저널 목록 — things-factory 표준 목록 계약(`ListParam` → `{ items, total }`).
45
+ *
46
+ * ── 왜 `twinEvents` 와 따로 두는가 ─────────────────────────────────────────
47
+ * `twinEvents` 는 배열만 돌려준다. 화면은 자기가 받은 게 전부인지 잘린 건지 알 수 없었고, 그래서
48
+ * 리스트가 **조용히 잘린 채 "이게 전부" 처럼** 보였다(원장 limit 120 이 대표적). 총건수를 함께
49
+ * 주면 "48 / 12,904" 라고 말할 수 있고, 사용자는 좁혀야 한다는 사실을 안다.
50
+ * 기존 호출자를 깨지 않으려고 `twinEvents` 는 그대로 두고 목록 계약을 새로 연다.
51
+ *
52
+ * ── 검색이 진짜인 이유 ─────────────────────────────────────────────────────
53
+ * `searchables` 는 전부 **인덱스 가능한 승격 컬럼**이다(payload JSON 안이 아니라). 프레임워크
54
+ * 질의 빌더는 `searchables` 에 없는 컬럼의 LIKE 를 경고 후 무시한다 — 인덱스 없는 전체 스캔을
55
+ * 막기 위해서다. 그 규율에 맞추려고 검색 축을 컬럼으로 승격했다(`twin-event-keys.ts`).
56
+ *
57
+ * 정렬 기본값은 revision DESC(최신순) — 저널의 자연 순서이자 ix_twin_event_0 가 그대로 타는 축.
58
+ */
59
+ @Query(returns => TwinEventList, {
60
+ description:
61
+ 'List twin journal events with the standard list contract (filters, pagination, sortings) plus the total match count. ' +
62
+ 'Scope is either one twin (instanceId) or a whole site (spaceId, which folds in every running operational twin co-located there, the same targets twinKpi uses) — one of the two is required. ' +
63
+ 'Searchable fields are the columns promoted out of the payload: epc (item), orderId, locationId, moverId (equipment), bizStep, eventType. ' +
64
+ 'Returns total so a caller can show an honest "shown of total" count instead of silently truncating a long journal. ' +
65
+ 'Defaults to newest first (revision descending) and to a page of 100; limit is capped at 500 per page.'
66
+ })
67
+ async twinEventList(
68
+ @Args(type => ListParam) params: ListParam,
69
+ @Ctx() context: ResolverContext,
70
+ @Arg('instanceId', { nullable: true, description: 'Read one twin instance journal.' }) instanceId?: string,
71
+ @Arg('spaceId', { nullable: true, description: 'Read every running operational twin co-located in this space, merged on the shared time axis.' }) spaceId?: string
72
+ ): Promise<TwinEventList> {
73
+ const { domain } = context.state
74
+ if (!instanceId && !spaceId) throw new Error('either instanceId or spaceId is required')
75
+ /* 테넌트 격리 — twinKpi 와 같은 규약. 남의 트윈은 빈 결과가 아니라 명시 실패로 알린다
76
+ * (조용한 빈 목록은 "권한 없음" 과 "데이터 없음" 을 구분할 수 없게 만든다). */
77
+ if (instanceId && !TwinEngine.owns(domain.id, instanceId)) {
78
+ throw new Error(`twin instance not found in this tenant: ${instanceId}`)
79
+ }
80
+
81
+ /* 대상 해소는 KPI 와 같은 창구를 쓴다 — 같은 공간을 보면서 화면마다 대상이 달라지면 안 된다. */
82
+ const instanceIds = await resolveTwinTargets(domain.id, instanceId, spaceId)
83
+ if (instanceIds.length === 0) return { items: [], total: 0 }
84
+
85
+ /* 페이지 상한 — 화면이 실수로(또는 악의로) 저널 전체를 한 번에 달라고 해도 서버가 버틴다. */
86
+ const limit = Math.min(params.pagination?.limit ?? 100, 500)
87
+ const effective: ListParam = {
88
+ ...params,
89
+ pagination: { page: params.pagination?.page ?? 1, limit },
90
+ /* 기본 정렬은 시각 내림차순 — 여러 트윈을 합칠 때 공통 축은 revision(트윈별 카운터)이 아니라
91
+ * **시각**이다. revision 으로 섞으면 서로 다른 트윈의 무관한 카운터가 뒤엉킨다. */
92
+ sortings: params.sortings?.length ? params.sortings : [{ name: 'eventTime', desc: true }]
93
+ }
94
+
95
+ const qb = getQueryBuilderFromListParams({
96
+ repository: getRepository(TwinEvent),
97
+ params: effective,
98
+ domain,
99
+ /* 전부 인덱스 가능한 승격 컬럼 — 자유 검색어는 서버가 이 축들로 펼친다. */
100
+ searchables: ['epc', 'orderId', 'locationId', 'moverId', 'bizStep', 'eventType'],
101
+ /* 정렬은 인덱스가 받쳐 주는 축으로만 — 큰 저널에서 임의 컬럼 정렬은 전체 정렬 스캔이다. */
102
+ sortables: ['eventTime', 'revision', 'eventType', 'bizStep'],
103
+ defaultLimit: 100,
104
+ maxLimit: 500
105
+ })
106
+ /* 대상 범위는 호출자가 필터로 빼먹을 수 있는 값이 아니다 — 계약상 필수라 여기서 강제한다. */
107
+ qb.andWhere(`${qb.alias}.instanceId IN (:...instanceIds)`, { instanceIds })
108
+
109
+ const [items, total] = await qb.getManyAndCount()
110
+
111
+ /* 다음 커서는 **이번 페이지의 마지막 행**에서 만든다. 페이지가 상한보다 짧으면 뒤가 없다. */
112
+ const axes = cursorSortings(effective.sortings, 'id')
113
+ const nextCursor = items.length === limit ? buildNextCursor(items[items.length - 1], axes) : undefined
114
+
115
+ return { items, total, nextCursor }
116
+ }
117
+
42
118
  /**
43
119
  * 업무 KPI — 시간창 처리량·소요시간·자원 점유. 저널을 **접어서** 만든다(새 계측을 심지 않는다).
44
120
  *
@@ -154,6 +154,28 @@ test('payload 가 envelope 없이 델타만 와도 읽는다 — 저장 형태
154
154
  assert.equal(r.workTime.avgMs, 20000)
155
155
  })
156
156
 
157
+ test('완료가 재전송되면 한 건으로 센다 — 처리량·자원 점유가 부풀려지지 않게', () => {
158
+ /* 공정 타임라인과 KPI 가 같은 저널을 다르게 짝지어 다른 답을 냈다(타임라인 1건 / KPI 2건).
159
+ * 두 구현이 있었기 때문이고, 규칙은 하나여야 한다: **완료는 작업당 하나, 나중 것이 사실.** */
160
+ const r = foldKpi(
161
+ [
162
+ task('t1', 'created', 0),
163
+ task('t1', 'in-progress', 10, 'fk-1'),
164
+ task('t1', 'completed', 40, 'fk-1'),
165
+ task('t1', 'completed', 45, 'fk-1') // 재전송
166
+ ],
167
+ WINDOW
168
+ )
169
+ assert.equal(r.throughput.tasks, 1, '한 건이다')
170
+ assert.equal(r.workTime.count, 1)
171
+ assert.equal(r.workTime.avgMs, 35000, '나중 완료(45s) 기준')
172
+ assert.deepEqual(
173
+ r.utilization.map(u => [u.resourceId, u.busyMs]),
174
+ [['fk-1', 35000]],
175
+ '점유도 한 번만 더한다(예전엔 65초로 부풀려졌다)'
176
+ )
177
+ })
178
+
157
179
  test('같은 전이가 두 번 와도 착수는 처음 것, 생성은 마지막 것 — 재시도에 흔들리지 않게', () => {
158
180
  const r = foldKpi(
159
181
  [
@@ -0,0 +1,108 @@
1
+ /*
2
+ * ★ 저널 검색 키 승격 검증 — payload(JSON) 안의 값을 인덱스 가능한 실컬럼으로 꺼내는 규칙.
3
+ *
4
+ * 이 규칙이 틀리면 "저널에는 있는데 검색으로는 안 나오는 이벤트" 가 생긴다. 화면에서는 그냥
5
+ * **없는 것으로 보인다** — 저널에서 가장 나쁜 결함이라 규칙을 테스트로 고정한다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { bizStepOf, epcOf, orderOf, locationOf, moverOf, twinEventKeys } from '../server/service/twin-event/twin-event-keys.ts'
11
+
12
+ /** EPCIS ObjectEvent 한 건(입고) — 실제 인제스트가 싣는 모양. */
13
+ const EPCIS_OBJECT = {
14
+ eventType: 'epcis.ObjectEvent',
15
+ eventTime: '2026-07-31T02:10:00.000Z',
16
+ data: {
17
+ bizStep: 'urn:epcglobal:cbv:bizstep:receiving',
18
+ disposition: 'urn:epcglobal:cbv:disp:in_progress',
19
+ epcList: ['urn:epc:id:sgtin:0614141.107346.2018'],
20
+ readPoint: { id: 'urn:epc:id:sgln:0614141.00777.DOCK-3' },
21
+ bizLocation: { id: 'urn:epc:id:sgln:0614141.00888.0' },
22
+ bizTransactionList: [{ type: 'urn:epcglobal:cbv:btt:po', bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }]
23
+ }
24
+ }
25
+
26
+ test('EPCIS 이벤트에서 네 축이 모두 뽑힌다', () => {
27
+ const k = twinEventKeys(EPCIS_OBJECT)
28
+ assert.equal(k.bizStep, 'receiving')
29
+ assert.equal(k.epc, 'urn:epc:id:sgtin:0614141.107346.2018')
30
+ assert.equal(k.orderId, 'urn:epc:id:gdti:0614141.402.2')
31
+ assert.equal(k.locationId, 'urn:epc:id:sgln:0614141.00777.DOCK-3')
32
+ })
33
+
34
+ test('식별자는 끝마디가 아니라 전체를 저장한다 — 부분일치 검색이 양방향으로 성립해야 한다', () => {
35
+ const k = twinEventKeys(EPCIS_OBJECT)
36
+ /* 화면은 '2018' 로 축약해 보여주지만 색인은 원본을 갖는다. contains 검색이라
37
+ * 끝마디로도 URN 전체로도 찾힌다 — 끝마디만 저장하면 URN 경로가 사라진다. */
38
+ assert.ok(k.epc!.includes('2018'), '끝마디로 찾을 수 있어야 한다')
39
+ assert.ok(k.epc!.startsWith('urn:epc:id:sgtin'), 'URN 전체로도 찾을 수 있어야 한다')
40
+ })
41
+
42
+ test('readPoint 가 없으면 bizLocation 으로 내려간다', () => {
43
+ const ev = { ...EPCIS_OBJECT, data: { ...EPCIS_OBJECT.data, readPoint: undefined } }
44
+ assert.equal(locationOf(ev), 'urn:epc:id:sgln:0614141.00888.0')
45
+ })
46
+
47
+ test('bizStep 이 없으면 이벤트 타입에서 유추한다 — 운영 델타도 단계를 말할 수 있어야 한다', () => {
48
+ assert.equal(bizStepOf({ eventType: 'task.status', data: { task: 't1' } }), 'task.status')
49
+ assert.equal(bizStepOf({ eventType: 'epcis.AggregationEvent', data: {} }), 'AggregationEvent')
50
+ })
51
+
52
+ test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목으로 쓴다', () => {
53
+ assert.equal(epcOf({ data: { parentID: 'urn:epc:id:sscc:0614141.1234567890' } }), 'urn:epc:id:sscc:0614141.1234567890')
54
+ assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*')
55
+ })
56
+
57
+ test('운영 델타의 오더는 bizTransactionList 가 아니라 order 필드에 있다', () => {
58
+ assert.equal(orderOf({ eventType: 'order.status', data: { order: 'SO-10021', status: 'released' } }), 'SO-10021')
59
+ })
60
+
61
+ test('없는 값은 빈 문자열이 아니라 undefined — 결측과 빈값을 섞지 않는다', () => {
62
+ const k = twinEventKeys({ eventType: 'equipment.status', data: { equipment: 'fork-1', status: 'idle' } })
63
+ assert.equal(k.epc, undefined)
64
+ assert.equal(k.orderId, undefined)
65
+ assert.equal(k.locationId, undefined)
66
+ assert.equal(k.moverId, undefined)
67
+ /* bizStep 은 이벤트 타입에서 유추되므로 남는다 — 이건 결측이 아니다. */
68
+ assert.equal(k.bizStep, 'equipment.status')
69
+ })
70
+
71
+ test('설비·무버 축 — 운영 델타의 moverId 를 뽑는다', () => {
72
+ const ev = { eventType: 'equipment.status', data: { moverId: 'fork-3', status: 'down', location: 'rack-1' } }
73
+ assert.equal(moverOf(ev), 'fork-3')
74
+ const k = twinEventKeys(ev)
75
+ assert.equal(k.moverId, 'fork-3')
76
+ /* 운영 델타는 readPoint/bizLocation 이 없다 — 평범한 location 을 위치로 받는다.
77
+ * 빠뜨리면 "이 설비가 어디서 무엇을 했나" 가 위치 축에서 통째로 사라진다. */
78
+ assert.equal(k.locationId, 'rack-1')
79
+ })
80
+
81
+ test('EPCIS 의 readPoint 는 운영 델타의 location 보다 우선한다', () => {
82
+ assert.equal(locationOf({ data: { readPoint: { id: 'DOCK-1' }, location: 'rack-9' } }), 'DOCK-1')
83
+ })
84
+
85
+ test('payload 가 data 로 감싸이지 않은 모양도 받는다', () => {
86
+ assert.equal(epcOf({ epcList: ['urn:epc:id:sgtin:1.2.3'] }), 'urn:epc:id:sgtin:1.2.3')
87
+ })
88
+
89
+ test('상한을 넘는 값은 조용히 사라지지 않는다 — 잘리되 경고가 남는다', () => {
90
+ const long = 'urn:epc:id:sgtin:' + 'x'.repeat(400)
91
+ const warned: string[] = []
92
+ const orig = console.warn
93
+ console.warn = (...a: unknown[]) => void warned.push(String(a[0]))
94
+ try {
95
+ const k = twinEventKeys({ data: { epcList: [long] } })
96
+ assert.equal(k.epc!.length, 255)
97
+ assert.equal(warned.length, 1, '잘렸다는 사실이 어딘가에는 남아야 한다')
98
+ assert.ok(warned[0].includes('epc'))
99
+ } finally {
100
+ console.warn = orig
101
+ }
102
+ })
103
+
104
+ test('빈 봉투에도 터지지 않는다', () => {
105
+ const empty = { bizStep: undefined, epc: undefined, orderId: undefined, locationId: undefined, moverId: undefined }
106
+ assert.deepEqual(twinEventKeys(undefined), empty)
107
+ assert.deepEqual(twinEventKeys({}), empty)
108
+ })
@@ -0,0 +1,78 @@
1
+ /*
2
+ * ★ 웜스타트 판정 검증 — 재기동하는 커널에 직전 관측 상태를 심을지.
3
+ *
4
+ * 이 판정은 **양쪽으로 조용히 틀린다**: 심어야 할 때 안 심으면 있는 재고가 0 으로 보이고
5
+ * (2026-07-31 hatiolab-wms — 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다),
6
+ * 벤치에 심으면 용량 측정이 오염된다. 둘 다 화면에는 아무 이상이 없어 보인다.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import { planWarmStart } from '../server/engine/warm-start.ts'
12
+
13
+ const STATE = {
14
+ nodes: [{ id: 'dock-recv' }, { id: 'rack-1' }],
15
+ items: [{ epc: 'a' }, { epc: 'b' }, { epc: 'c' }],
16
+ movers: [{ id: 'fork-1' }],
17
+ orders: [{ id: 'SO-1', kind: 'outbound', status: 'created', progress: 0.5 }],
18
+ tasks: [{ id: 't1' }]
19
+ }
20
+
21
+ test('상태가 있으면 심는다 — 재고·노드·무버', () => {
22
+ const plan = planWarmStart(STATE, undefined, true)
23
+ assert.equal(plan.action, 'hydrate')
24
+ if (plan.action !== 'hydrate') return
25
+ assert.equal(plan.itemCount, 3)
26
+ assert.equal(plan.moverCount, 1)
27
+ assert.deepEqual(plan.seed.nodes, STATE.nodes)
28
+ })
29
+
30
+ test('오더는 심지 않는다 — 스냅샷에 requested/fulfilled 가 없어 지어내야 하기 때문', () => {
31
+ const plan = planWarmStart(STATE, undefined, true)
32
+ assert.equal(plan.action, 'hydrate')
33
+ if (plan.action !== 'hydrate') return
34
+ assert.deepEqual(Object.keys(plan.seed).sort(), ['items', 'movers', 'nodes'])
35
+ assert.equal((plan.seed as any).orders, undefined)
36
+ assert.equal((plan.seed as any).tasks, undefined)
37
+ })
38
+
39
+ test('벤치 트윈에는 심지 않는다 — 새 시작에서 용량을 재는 것이 목적이다', () => {
40
+ const plan = planWarmStart(STATE, 'bench', true)
41
+ assert.deepEqual(plan, { action: 'skip', reason: 'bench' })
42
+ })
43
+
44
+ test('벤치 판정은 상태 유무보다 먼저다 — 상태가 없어도 이유는 bench 여야 한다', () => {
45
+ /* 이유가 뒤바뀌면 로그가 "심을 게 없었다" 로 남아, 벤치를 의도적으로 비웠다는 사실이 사라진다. */
46
+ assert.deepEqual(planWarmStart(null, 'bench', true), { action: 'skip', reason: 'bench' })
47
+ })
48
+
49
+ test('심을 상태가 없으면 조용히 넘어간다 — 처음 만든 트윈의 정상 경로다', () => {
50
+ assert.deepEqual(planWarmStart(null, undefined, true), { action: 'skip', reason: 'no-state' })
51
+ assert.deepEqual(planWarmStart(undefined, undefined, true), { action: 'skip', reason: 'no-state' })
52
+ assert.deepEqual(planWarmStart({ nodes: [], items: [], movers: [] }, undefined, true), { action: 'skip', reason: 'no-state' })
53
+ })
54
+
55
+ test('노드만 있어도 심는다 — 구조 배치가 복원되면 화면이 살아난다', () => {
56
+ const plan = planWarmStart({ nodes: [{ id: 'n1' }], items: [], movers: [] }, undefined, true)
57
+ assert.equal(plan.action, 'hydrate')
58
+ if (plan.action !== 'hydrate') return
59
+ assert.equal(plan.itemCount, 0)
60
+ })
61
+
62
+ test('커널이 주입을 지원하지 않으면 알린다 — 조용히 빈 채로 뜨면 안 된다', () => {
63
+ assert.deepEqual(planWarmStart(STATE, undefined, false), { action: 'skip', reason: 'unsupported' })
64
+ })
65
+
66
+ test('심을 게 없으면 미지원이어도 경고할 일이 아니다', () => {
67
+ /* 미지원 경고가 매 기동마다 뜨면 진짜 경고가 묻힌다. */
68
+ assert.deepEqual(planWarmStart(null, undefined, false), { action: 'skip', reason: 'no-state' })
69
+ })
70
+
71
+ test('빠진 배열은 빈 배열로 다뤄지되 없는 것을 지어내지 않는다', () => {
72
+ const plan = planWarmStart({ items: [{ epc: 'a' }] } as any, undefined, true)
73
+ assert.equal(plan.action, 'hydrate')
74
+ if (plan.action !== 'hydrate') return
75
+ assert.deepEqual(plan.seed.nodes, [])
76
+ assert.deepEqual(plan.seed.movers, [])
77
+ assert.equal(plan.itemCount, 1)
78
+ })