@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.0.5",
3
+ "version": "10.0.6",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh <heartyoh@hatiolab.com>",
@@ -25,11 +25,11 @@
25
25
  "test": "node --test test/*.test.ts"
26
26
  },
27
27
  "dependencies": {
28
- "@operato/twin-kernel": "^0.0.6",
29
- "@things-factory/auth-base": "^10.0.5",
30
- "@things-factory/cache-service": "^10.0.5",
28
+ "@operato/twin-kernel": "^0.1.0",
29
+ "@things-factory/auth-base": "^10.0.6",
30
+ "@things-factory/cache-service": "^10.0.6",
31
31
  "@things-factory/env": "^10.0.0",
32
- "@things-factory/shell": "^10.0.5"
32
+ "@things-factory/shell": "^10.0.6"
33
33
  },
34
- "gitHead": "42240a9b6d504181850ea30188f215434b2dd55f"
34
+ "gitHead": "b929c16f83f70bfcc9a144ac6a50232efa56cd64"
35
35
  }
@@ -19,6 +19,14 @@
19
19
  * · DB·커널을 모른다. 입력은 평범한 배열이고 출력은 평범한 객체다(테스트가 값싸다).
20
20
  */
21
21
 
22
+ /*
23
+ * 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것,
24
+ * 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니
25
+ * 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다).
26
+ * 이 파일은 이제 "접힌 기록 → 창 지표" 만 한다.
27
+ */
28
+ import { foldTaskRecords, type TaskFacets } from '@operato/twin-kernel'
29
+
22
30
  /** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
23
31
  export interface KpiEvent {
24
32
  eventType?: string
@@ -121,14 +129,6 @@ export interface KpiFoldOptions {
121
129
  * 축의 값은 이벤트 payload 에 이미 들어 있다(`TaskStatusDelta`: kind·orderId·fromNode·toNode·
122
130
  * resourceRef). 새 계측을 심을 필요가 없다 — **이미 쌓인 저널을 다르게 묶기만** 한다.
123
131
  */
124
- interface TaskFacets {
125
- resource?: string
126
- taskKind?: string
127
- /** 작업이 **도착한** 지점. 없으면 출발 지점(둘 다 없으면 축은 'unknown'). */
128
- node?: string
129
- order?: string
130
- }
131
-
132
132
  /** 창 안에 완료된 한 건의 기록 — 통계의 원료이자 관점 축의 원료. */
133
133
  interface CompletionRecord {
134
134
  taskId: string
@@ -139,23 +139,6 @@ interface CompletionRecord {
139
139
  waitMs?: number
140
140
  }
141
141
 
142
- /**
143
- * 이 작업에 대해 본 축의 값을 기억한다 — **처음 본 값을 남긴다**.
144
- *
145
- * 왜 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.
146
- * 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.
147
- */
148
- function rememberFacets(store: Map<string, TaskFacets>, id: string, d: any): void {
149
- const prev = store.get(id) ?? {}
150
- const node = d.toNode ?? d.fromNode
151
- store.set(id, {
152
- resource: prev.resource ?? (d.resourceRef || undefined),
153
- taskKind: prev.taskKind ?? (d.kind || undefined),
154
- order: prev.order ?? (d.orderId || undefined),
155
- node: node || prev.node
156
- })
157
- }
158
-
159
142
  /** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */
160
143
  function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
161
144
  const f = rec.facets
@@ -163,7 +146,7 @@ function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
163
146
  case 'resource':
164
147
  return f.resource ?? 'unknown'
165
148
  case 'taskKind':
166
- return f.taskKind ?? 'unknown'
149
+ return f.kind ?? 'unknown'
167
150
  case 'node':
168
151
  return f.node ?? 'unknown'
169
152
  case 'order':
@@ -257,13 +240,8 @@ function stats(values: number[]): DurationStats {
257
240
  * (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
258
241
  */
259
242
  export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldOptions = {}): KpiResult {
260
- /* 작업별 이정표 시각 마지막 값을 남긴다(재시도로 같은 전이가 오면 나중 것이 사실). */
261
- const created = new Map<string, number>()
262
- const started = new Map<string, number>()
263
- /* 축의 값은 **어느 전이에서든** 올 수 있다(완료 이벤트에 kind 가 빠져 있고 생성에만 있는 구현이 있다).
264
- * 그래서 작업별로 한 번 본 값을 기억한다 — 축이 이벤트 모양에 따라 통째로 비는 것을 막는다. */
265
- const facets = new Map<string, TaskFacets>()
266
- const completedTasks: { id: string; at: number; resourceId?: string }[] = []
243
+ /* 작업 짝맞춤은 **커널 규칙**으로(중복 구현 금지 곳에 적으면 같은 저널로 다른 답이 나온다). */
244
+ const { records: tasks } = foldTaskRecords(events.filter(e => e.eventType === 'task.status'))
267
245
  let completedOrders = 0
268
246
  let seen = 0
269
247
  let inWindow = 0
@@ -275,15 +253,6 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
275
253
  if (at >= window.fromMs && at <= window.toMs) inWindow++
276
254
  const d = e.payload?.data ?? e.payload ?? {}
277
255
 
278
- if (e.eventType === 'task.status') {
279
- const id = d.taskId
280
- if (!id) continue
281
- rememberFacets(facets, id, d)
282
- if (d.status === 'created') created.set(id, at)
283
- else if (d.status === 'in-progress' && !started.has(id)) started.set(id, at)
284
- else if (d.status === 'completed') completedTasks.push({ id, at, resourceId: d.resourceRef })
285
- continue
286
- }
287
256
  if (e.eventType === 'order.status') {
288
257
  /* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고
289
258
  * 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */
@@ -305,19 +274,21 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
305
274
  let unpairedLead = 0
306
275
  let unpairedWork = 0
307
276
 
308
- for (const t of completedTasks) {
309
- if (t.at < window.fromMs || t.at > window.toMs) continue // 성과는 완료 시점으로 센다
277
+ for (const t of tasks) {
278
+ const completed = t.completedMs
279
+ if (completed === undefined) continue // 아직 완료되지 않은 작업 — 성과로 세지 않는다
280
+ if (completed < window.fromMs || completed > window.toMs) continue // 성과는 완료 시점으로 센다
310
281
  tasksInWindow++
311
- const c = created.get(t.id)
312
- const s = started.get(t.id)
313
- const rec: CompletionRecord = { taskId: t.id, at: t.at, facets: facets.get(t.id) ?? {} }
314
- if (t.resourceId) rec.facets = { ...rec.facets, resource: t.resourceId }
315
- if (c !== undefined && t.at >= c) leads.push((rec.leadMs = t.at - c))
282
+ const c = t.createdMs
283
+ const s = t.startedMs
284
+ const rec: CompletionRecord = { taskId: t.taskId, at: completed, facets: t.facets }
285
+ const resource = rec.facets.resource
286
+ if (c !== undefined && completed >= c) leads.push((rec.leadMs = completed - c))
316
287
  else unpairedLead++
317
- if (s !== undefined && t.at >= s) {
318
- const busy = t.at - s
288
+ if (s !== undefined && completed >= s) {
289
+ const busy = completed - s
319
290
  works.push((rec.workMs = busy))
320
- if (t.resourceId) busyByResource.set(t.resourceId, (busyByResource.get(t.resourceId) ?? 0) + busy)
291
+ if (resource) busyByResource.set(resource, (busyByResource.get(resource) ?? 0) + busy)
321
292
  } else {
322
293
  unpairedWork++
323
294
  }
@@ -249,6 +249,17 @@ async function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: strin
249
249
  return { instanceIds, realityModes }
250
250
  }
251
251
 
252
+ /**
253
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
254
+ *
255
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
256
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
257
+ */
258
+ export async function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]> {
259
+ const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId } as TwinKpiInput)
260
+ return instanceIds
261
+ }
262
+
252
263
  /**
253
264
  * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
254
265
  *
@@ -11,6 +11,8 @@ import { pubsub, getRepository, Domain } from '@things-factory/shell'
11
11
  import { cacheService } from '@things-factory/cache-service'
12
12
 
13
13
  import { TwinEvent } from '../service/twin-event/twin-event.js'
14
+ import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
15
+ import { planWarmStart } from './warm-start.js'
14
16
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
15
17
  import { TwinSpace } from '../service/twin-space/twin-space.js'
16
18
  import { TwinSpaceRepresentation } from '../service/twin-space/twin-space-representation.js'
@@ -177,19 +179,62 @@ export class TwinEngine {
177
179
  }
178
180
  }
179
181
 
180
- /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
181
- static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode): InstanceRuntime {
182
+ /**
183
+ * 웜스타트 기동하는 커널에 **직전 관측 상태**를 심는다.
184
+ *
185
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
186
+ * `loadBoard` 는 **구조만** 싣는다(노드·무버). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
187
+ * 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
188
+ * 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
189
+ * `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
190
+ * 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다.
191
+ *
192
+ * ── 정직한 한계 ─────────────────────────────────────────────────────────────
193
+ * · **오더는 복원하지 않는다.** `hydrateObserved` 의 오더 인자는 requested/fulfilled/lines 를 요구하는데
194
+ * 스냅샷의 `OrderState` 에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이라
195
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작한다.
196
+ * · 진행 중 개별 task 의 내부 상태도 관측만으로는 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
197
+ * · 근본 해법(상태 영속 계약·revision 이어붙임)은 별도 과제.
198
+ *
199
+ * ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
200
+ * 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 심으면 측정이 오염된다.
201
+ */
202
+ private static warmStart(id: string, kernel: TwinKernel, purpose?: string): void {
203
+ const hydrate = (kernel as any).hydrateObserved
204
+ const plan = planWarmStart(this.recovered[id]?.state, purpose, typeof hydrate === 'function')
205
+
206
+ if (plan.action === 'skip') {
207
+ if (plan.reason === 'bench') {
208
+ console.log(`[twin-engine] "${id}" is a bench twin — starting empty on purpose (seeding would skew the measurement).`)
209
+ } else if (plan.reason === 'unsupported') {
210
+ console.warn(
211
+ `[twin-engine] kernel for "${id}" cannot be warm-started (no hydrateObserved) — it starts with structure only, so held stock will read as zero.`
212
+ )
213
+ }
214
+ return
215
+ }
216
+
217
+ hydrate.call(kernel, plan.seed)
218
+ console.log(
219
+ `[twin-engine] warm-started "${id}" — ${plan.itemCount} item(s), ${plan.moverCount} mover(s) restored. ` +
220
+ 'Open orders are not restored (the snapshot carries no requested/fulfilled counts).'
221
+ )
222
+ }
223
+
224
+ /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
225
+ static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode, purpose?: string): InstanceRuntime {
182
226
  if (this.instances[id]) return this.instances[id]
183
227
 
184
228
  const Kernel = KERNELS[kind] ?? WmsKernel
185
229
  const kernel: TwinKernel = new Kernel(domainId)
186
- kernel.loadBoard(board)
230
+ kernel.loadBoard(board) // 구조만. 상태는 아래 웜스타트가 심는다.
231
+ this.warmStart(id, kernel, purpose)
187
232
  const runtime: TwinRuntimeType = new TwinRuntime(kernel)
188
233
 
189
234
  /* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
190
235
  const inst: InstanceRuntime = { id, domainId, runtime, kernel, realityMode: realityMode ?? DEFAULT_REALITY_MODE, unsub: () => {} }
191
236
  this.instances[id] = inst
192
- delete this.recovered[id] // 라이브가 우선
237
+ delete this.recovered[id] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
193
238
 
194
239
  /* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
195
240
  getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
@@ -312,20 +357,28 @@ export class TwinEngine {
312
357
  if (!anyLive && this.broadcastTimer) { clearInterval(this.broadcastTimer); this.broadcastTimer = undefined } // live 없으면 tick 정지
313
358
  }
314
359
 
360
+ /**
361
+ * 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.**
362
+ * 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
363
+ * 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
364
+ */
365
+ private static journalRow(repo: any, domainId: string, instanceId: string, e: any, revision: number) {
366
+ return repo.create({
367
+ domain: { id: domainId } as any,
368
+ instanceId,
369
+ tenantId: e?.tenantId,
370
+ eventType: e?.eventType,
371
+ revision,
372
+ eventTime: e?.eventTime,
373
+ ...twinEventKeys(e),
374
+ payload: e
375
+ })
376
+ }
377
+
315
378
  /** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
316
379
  static async persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise<void> {
317
380
  const repo = getRepository(TwinEvent)
318
- const rows = envelopes.map((e, i) =>
319
- repo.create({
320
- domain: { id: domainId } as any,
321
- instanceId,
322
- tenantId: e?.tenantId,
323
- eventType: e?.eventType,
324
- revision: startRevision + i + 1,
325
- eventTime: e?.eventTime,
326
- payload: e
327
- })
328
- )
381
+ const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1))
329
382
  await repo.save(rows, { chunk: 500 })
330
383
  }
331
384
 
@@ -417,7 +470,32 @@ export class TwinEngine {
417
470
  if (this.instances[instanceId]) return this.instances[instanceId]
418
471
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
419
472
  if (!reg?.board) throw new Error(`instance "${instanceId}" not provisioned (no board)`)
420
- return this.start(instanceId, domainId, reg.kind ?? 'wms', reg.board as BoardDef, realityMode ?? (reg.realityMode as RealityMode) ?? undefined)
473
+
474
+ /*
475
+ * 웜스타트 재료를 **여기서 확실히 확보한다.**
476
+ * `start()` 는 동기라 스스로 캐시를 읽을 수 없어서 `recovered` 에 미리 담겨 있기를 기대하는데,
477
+ * 그건 `bootstrap()` 이 먼저 돌았을 때만 참이다. 부팅 순서에 기대면 어떤 날은 재고가 살아나고
478
+ * 어떤 날은 조용히 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다.
479
+ * 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
480
+ */
481
+ if (!this.recovered[instanceId] && reg.purpose !== 'bench') {
482
+ const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
483
+ if (cached?.state) {
484
+ this.recovered[instanceId] = { revision: cached.revision, state: cached.state }
485
+ } else {
486
+ const state = await this.recover(domainId, instanceId).catch(() => null)
487
+ if (state) this.recovered[instanceId] = { revision: state.revision, state }
488
+ }
489
+ }
490
+
491
+ return this.start(
492
+ instanceId,
493
+ domainId,
494
+ reg.kind ?? 'wms',
495
+ reg.board as BoardDef,
496
+ realityMode ?? (reg.realityMode as RealityMode) ?? undefined,
497
+ reg.purpose ?? undefined
498
+ )
421
499
  }
422
500
 
423
501
  /**
@@ -681,18 +759,7 @@ export class TwinEngine {
681
759
 
682
760
  static async persist(domainId: string, instanceId: string, msg: any): Promise<void> {
683
761
  const repo = getRepository(TwinEvent)
684
- const e = msg.event
685
- await repo.save(
686
- repo.create({
687
- domain: { id: domainId } as any,
688
- instanceId,
689
- tenantId: e?.tenantId,
690
- eventType: e?.eventType,
691
- revision: msg.revision,
692
- eventTime: e?.eventTime,
693
- payload: e
694
- })
695
- )
762
+ await repo.save(this.journalRow(repo, domainId, instanceId, msg.event, msg.revision))
696
763
  }
697
764
 
698
765
  /**
@@ -0,0 +1,53 @@
1
+ /*
2
+ * 웜스타트 판정 — **순수**. "기동하는 커널에 직전 상태를 심을 것인가, 심는다면 무엇을" 만 정한다.
3
+ * 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다.
4
+ *
5
+ * ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────
6
+ * 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 심어야 할 때 안 심으면 **있는 재고가 0 으로**
7
+ * 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지
8
+ * 말아야 할 벤치에 심으면 **용량 측정이 오염된다.** 둘 다 조용히 틀리는 종류라 규칙을 고정한다.
9
+ */
10
+
11
+ /** 커널에 심을 관측 상태 — 구조가 아니라 "무엇이 어디에 얼마나". */
12
+ export interface ObservedSeed {
13
+ nodes: unknown[]
14
+ items: unknown[]
15
+ movers: unknown[]
16
+ }
17
+
18
+ export type WarmStartPlan =
19
+ | { action: 'hydrate'; seed: ObservedSeed; itemCount: number; moverCount: number }
20
+ /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 심으면 측정이 오염된다. */
21
+ | { action: 'skip'; reason: 'bench' }
22
+ /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */
23
+ | { action: 'skip'; reason: 'no-state' }
24
+ /** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */
25
+ | { action: 'skip'; reason: 'unsupported' }
26
+
27
+ /**
28
+ * 무엇을 할지 정한다.
29
+ *
30
+ * **오더는 의도적으로 심지 않는다.** 커널의 관측 주입은 오더에 requested/fulfilled/lines 를 요구하는데
31
+ * 스냅샷의 오더에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이므로
32
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작하는 편이 정직하다.
33
+ */
34
+ export function planWarmStart(
35
+ state: { nodes?: unknown[]; items?: unknown[]; movers?: unknown[] } | null | undefined,
36
+ purpose: string | undefined,
37
+ canHydrate: boolean
38
+ ): WarmStartPlan {
39
+ /* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 심지 않는다는 사실이 바뀌지 않는다. */
40
+ if (purpose === 'bench') return { action: 'skip', reason: 'bench' }
41
+ if (!state) return { action: 'skip', reason: 'no-state' }
42
+
43
+ const nodes = state.nodes ?? []
44
+ const items = state.items ?? []
45
+ const movers = state.movers ?? []
46
+ /* 셋 다 비었으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
47
+ if (nodes.length === 0 && items.length === 0 && movers.length === 0) return { action: 'skip', reason: 'no-state' }
48
+
49
+ /* 지원 여부는 마지막에 본다 — 심을 게 있는데 못 심는 상황이라야 경고할 값어치가 있다. */
50
+ if (!canHydrate) return { action: 'skip', reason: 'unsupported' }
51
+
52
+ return { action: 'hydrate', seed: { nodes, items, movers }, itemCount: items.length, moverCount: movers.length }
53
+ }
package/server/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './service/index.js'
4
4
  import './routes.js'
5
5
 
6
6
  import { TwinEngine } from './engine/index.js'
7
+ import { backfillTwinEventKeys } from './service/twin-event/backfill-keys.js'
7
8
 
8
9
  /* 모듈 부팅 — 영속 인스턴스 복구 훅(향후). 지금은 명시 start(mutation/부팅 설정)로 인스턴스 생성. */
9
10
  process.on('bootstrap-module-start' as any, async ({ app, config, client }: any) => {
@@ -13,4 +14,12 @@ process.on('bootstrap-module-start' as any, async ({ app, config, client }: any)
13
14
  } catch (ex) {
14
15
  console.error('Headless Twin host failed to start.', ex)
15
16
  }
17
+
18
+ /*
19
+ * 승격 검색 키 백필 — 컬럼이 생기기 전에 쌓인 저널을 채운다. 멱등·재개 가능이라 매 기동 불러도
20
+ * 채울 게 없으면 조각 한 번 읽고 끝난다(로그도 남기지 않는다).
21
+ * 기동을 막지 않는다 — 저널이 크면 오래 걸릴 수 있고, 그동안 트윈은 정상 동작해야 한다.
22
+ * 실패해도 서비스는 계속된다: 못 채운 만큼 **과거 이력 검색이 덜 나올 뿐**이므로 조용히 삼키지 않고 알린다.
23
+ */
24
+ backfillTwinEventKeys().catch(ex => console.error('twin-event key backfill failed — search over older journal rows will be incomplete.', ex))
16
25
  })
@@ -0,0 +1,72 @@
1
+ import { IsNull } from 'typeorm'
2
+
3
+ import { getRepository } from '@things-factory/shell'
4
+
5
+ import { TwinEvent } from './twin-event.js'
6
+ import { twinEventKeys } from './twin-event-keys.js'
7
+
8
+ /*
9
+ * 승격 검색 키 백필 — 컬럼이 생기기 **전에** 쌓인 저널 행을 채운다.
10
+ *
11
+ * 이게 없으면 검색은 "오늘부터의 이력" 만 찾는다. 사용자에게는 그냥 **과거가 없는 것으로 보이고**,
12
+ * 그건 이번에 고치려던 결함(조용히 빠진 데이터)과 정확히 같은 종류다.
13
+ *
14
+ * 성질:
15
+ * · 멱등 — 이미 채워진 행은 건드리지 않는다(bizStep IS NULL 인 것만 집는다).
16
+ * · 재개 가능 — 중간에 죽어도 다음 기동에서 남은 것부터 이어간다.
17
+ * · 조각내서 — 한 번에 다 읽지 않는다. 저널은 크다는 전제로 만든다.
18
+ * · payload 는 손대지 않는다 — 정본은 그대로 두고 파생 색인만 채운다.
19
+ *
20
+ * 아주 큰 운영 저널이라면 기동 시 백그라운드보다 **마이그레이션으로 한 번** 도는 편이 낫다.
21
+ * 이 함수를 그대로 부르면 되므로 경로는 하나다.
22
+ */
23
+
24
+ const CHUNK = 1000
25
+
26
+ export interface BackfillResult {
27
+ /** 실제로 갱신한 행 수. */
28
+ updated: number
29
+ /** 훑었지만 payload 가 없어 채울 수 없던 행 수 — 0 이 아니면 인제스트 쪽을 봐야 한다. */
30
+ skipped: number
31
+ }
32
+
33
+ /**
34
+ * 남은 행을 전부 채운다. 진행 상황을 로그로 남긴다 — 조용히 오래 도는 작업은
35
+ * 멈춘 것과 구분되지 않는다.
36
+ */
37
+ export async function backfillTwinEventKeys(): Promise<BackfillResult> {
38
+ const repo = getRepository(TwinEvent)
39
+ let updated = 0
40
+ let skipped = 0
41
+ let round = 0
42
+
43
+ for (;;) {
44
+ /* bizStep 은 이벤트 타입에서라도 유추되므로 **정상 인제스트라면 반드시 채워진다** —
45
+ * 즉 NULL 은 "승격 이전 행" 의 확실한 표식이다. epc 로 판정하면 품목 없는 이벤트를
46
+ * 매번 다시 집어 무한히 돈다. */
47
+ const rows = await repo.find({ where: { bizStep: IsNull() }, take: CHUNK, order: { revision: 'ASC' } })
48
+ if (rows.length === 0) break
49
+
50
+ const dirty: TwinEvent[] = []
51
+ for (const row of rows) {
52
+ if (!row.payload) {
53
+ skipped++
54
+ continue
55
+ }
56
+ Object.assign(row, twinEventKeys(row.payload))
57
+ dirty.push(row)
58
+ }
59
+ if (dirty.length) await repo.save(dirty, { chunk: 500 })
60
+ updated += dirty.length
61
+
62
+ /* payload 가 없는 행만 남으면 같은 조각을 영원히 다시 읽는다 — 진도가 없으면 멈춘다. */
63
+ if (dirty.length === 0) break
64
+
65
+ if (++round % 10 === 0) console.log(`[twin-event backfill] ${updated} rows filled…`)
66
+ }
67
+
68
+ if (updated || skipped) {
69
+ console.log(`[twin-event backfill] done — ${updated} filled, ${skipped} skipped (no payload)`)
70
+ }
71
+ return { updated, skipped }
72
+ }
@@ -0,0 +1,102 @@
1
+ /*
2
+ * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.
3
+ *
4
+ * ── 왜 승격하는가 ───────────────────────────────────────────────────────────
5
+ * 사용자가 저널에서 실제로 찾는 것은 "이 팔레트의 이력", "이 오더가 어디까지 갔나", "이 도크에서
6
+ * 무슨 일이 있었나" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.
7
+ * things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —
8
+ * 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온
9
+ * 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.
10
+ *
11
+ * 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —
12
+ * 이건 파생 색인이지 새로운 진실이 아니다).
13
+ *
14
+ * ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────
15
+ * 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면
16
+ * 반드시 어긋나고, 어긋난 색인은 "없는 것처럼 보이는 이벤트" 를 만든다 — 저널에서 가장 나쁜 결함이다.
17
+ */
18
+
19
+ /** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */
20
+ export interface TwinEventKeys {
21
+ bizStep?: string
22
+ epc?: string
23
+ orderId?: string
24
+ locationId?: string
25
+ moverId?: string
26
+ }
27
+
28
+ /*
29
+ * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.
30
+ * 넘치는 값이 오면 **조용히 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,
31
+ * 그 사실이 어디에도 안 남으면 아무도 모른다.
32
+ */
33
+ const MAX_KEY = 255
34
+
35
+ function clip(v: unknown, field: string): string | undefined {
36
+ if (v === undefined || v === null) return undefined
37
+ const s = String(v)
38
+ if (!s) return undefined
39
+ if (s.length <= MAX_KEY) return s
40
+ console.warn(
41
+ `[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +
42
+ `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`
43
+ )
44
+ return s.slice(0, MAX_KEY)
45
+ }
46
+
47
+ /** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
48
+ export function bizStepOf(envelope: any): string | undefined {
49
+ const d = envelope?.data ?? envelope ?? {}
50
+ const tail = String(d.bizStep ?? '').split(':').pop()
51
+ return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined
52
+ }
53
+
54
+ /**
55
+ * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).
56
+ *
57
+ * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라
58
+ * 전체를 저장해 두면 끝마디("402.2")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면
59
+ * URN 으로 찾는 경로가 사라진다.
60
+ */
61
+ export function epcOf(envelope: any): string | undefined {
62
+ const d = envelope?.data ?? envelope ?? {}
63
+ return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined
64
+ }
65
+
66
+ /** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
67
+ export function orderOf(envelope: any): string | undefined {
68
+ const d = envelope?.data ?? envelope ?? {}
69
+ return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? undefined
70
+ }
71
+
72
+ /**
73
+ * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).
74
+ * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서
75
+ * 무엇을 했는지가 위치 축에서 통째로 사라진다.
76
+ */
77
+ export function locationOf(envelope: any): string | undefined {
78
+ const d = envelope?.data ?? envelope ?? {}
79
+ return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined
80
+ }
81
+
82
+ /**
83
+ * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.
84
+ *
85
+ * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 "이 지게차가 오늘 무엇을 했나" 를
86
+ * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 훑는 수밖에 없다.
87
+ */
88
+ export function moverOf(envelope: any): string | undefined {
89
+ const d = envelope?.data ?? envelope ?? {}
90
+ return d.moverId ?? undefined
91
+ }
92
+
93
+ /** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */
94
+ export function twinEventKeys(envelope: any): TwinEventKeys {
95
+ return {
96
+ bizStep: clip(bizStepOf(envelope), 'bizStep'),
97
+ epc: clip(epcOf(envelope), 'epc'),
98
+ orderId: clip(orderOf(envelope), 'orderId'),
99
+ locationId: clip(locationOf(envelope), 'locationId'),
100
+ moverId: clip(moverOf(envelope), 'moverId')
101
+ }
102
+ }
@@ -0,0 +1,27 @@
1
+ import { Field, Int, ObjectType } from 'type-graphql'
2
+
3
+ import { TwinEvent } from './twin-event.js'
4
+
5
+ /*
6
+ * 저널 목록 반환형 — things-factory 표준 `{ items, total }`(AttributeSetList·DomainList 등과 동형).
7
+ *
8
+ * `total` 이 이 타입의 존재 이유다. 기존 `twinEvents` 는 배열만 돌려줘서 **화면이 자기가 전체를 받은
9
+ * 건지 잘린 건지 알 방법이 없었다** — 그래서 리스트가 조용히 잘린 채로 "이게 전부" 처럼 보였다.
10
+ * 총건수를 함께 주면 화면은 "48 / 12,904" 라고 정직하게 말할 수 있고, 사용자는 좁혀야 한다는 걸 안다.
11
+ */
12
+ @ObjectType({ description: 'A page of twin journal events together with the total number of matching records.' })
13
+ export class TwinEventList {
14
+ @Field(type => [TwinEvent], { description: 'The events on this page, ordered by the requested sorting (revision descending by default).' })
15
+ items: TwinEvent[]
16
+
17
+ @Field(type => Int, { description: 'Total number of events matching the filters, ignoring pagination. Lets the caller show an honest "shown of total" count instead of silently truncating.' })
18
+ total: number
19
+
20
+ /*
21
+ * 다음 페이지 커서. 저널은 **머리에 계속 쌓이는 목록**이라 offset 으로 뒤를 읽으면
22
+ * 읽는 사이 들어온 이벤트만큼 밀려 본 행이 또 나오거나 못 본 행이 사라진다.
23
+ * 이 값을 그대로 다음 요청의 `pagination.after` 로 돌려주면 그 문제가 없다.
24
+ */
25
+ @Field({ nullable: true, description: 'Opaque cursor for the next page. Pass it back as pagination.after to continue exactly where this page ended, without the duplicate or skipped rows that offset paging produces on a journal that keeps growing at the head. Null when this page is the last one.' })
26
+ nextCursor?: string
27
+ }