@things-factory/headless-twin 10.0.4 → 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 (48) hide show
  1. package/dist-server/engine/index.d.ts +2 -0
  2. package/dist-server/engine/index.js +2 -0
  3. package/dist-server/engine/index.js.map +1 -1
  4. package/dist-server/engine/kpi-fold.d.ts +179 -0
  5. package/dist-server/engine/kpi-fold.js +270 -0
  6. package/dist-server/engine/kpi-fold.js.map +1 -0
  7. package/dist-server/engine/kpi-query.d.ts +156 -0
  8. package/dist-server/engine/kpi-query.js +287 -0
  9. package/dist-server/engine/kpi-query.js.map +1 -0
  10. package/dist-server/engine/twin-engine.d.ts +29 -2
  11. package/dist-server/engine/twin-engine.js +78 -21
  12. package/dist-server/engine/twin-engine.js.map +1 -1
  13. package/dist-server/engine/warm-start.d.ts +39 -0
  14. package/dist-server/engine/warm-start.js +37 -0
  15. package/dist-server/engine/warm-start.js.map +1 -0
  16. package/dist-server/index.js +8 -0
  17. package/dist-server/index.js.map +1 -1
  18. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  19. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  20. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  21. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  22. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  23. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  24. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  25. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  26. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  27. package/dist-server/service/twin-event/twin-event.d.ts +5 -0
  28. package/dist-server/service/twin-event/twin-event.js +45 -0
  29. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  30. package/dist-server/service/twin-journal/twin-journal-query.d.ts +43 -0
  31. package/dist-server/service/twin-journal/twin-journal-query.js +127 -0
  32. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  33. package/dist-server/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +6 -6
  35. package/server/engine/index.ts +2 -0
  36. package/server/engine/kpi-fold.ts +417 -0
  37. package/server/engine/kpi-query.ts +435 -0
  38. package/server/engine/twin-engine.ts +95 -28
  39. package/server/engine/warm-start.ts +53 -0
  40. package/server/index.ts +9 -0
  41. package/server/service/twin-event/backfill-keys.ts +72 -0
  42. package/server/service/twin-event/twin-event-keys.ts +102 -0
  43. package/server/service/twin-event/twin-event-type.ts +27 -0
  44. package/server/service/twin-event/twin-event.ts +48 -0
  45. package/server/service/twin-journal/twin-journal-query.ts +136 -2
  46. package/test/kpi-fold.test.ts +469 -0
  47. package/test/twin-event-keys.test.ts +108 -0
  48. package/test/warm-start.test.ts +78 -0
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
+ }
@@ -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,10 +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'
9
+ import { computeTwinKpi, resolveTwinTargets } from '../../engine/kpi-query.js'
8
10
 
9
11
  /*
10
12
  * 저널 읽기 채널 — 상향 query.
@@ -38,6 +40,138 @@ export class TwinJournalQuery {
38
40
  return getRepository(TwinEvent).find({ where, order: { revision: 'DESC' }, take: limit ?? 200 })
39
41
  }
40
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
+
118
+ /**
119
+ * 업무 KPI — 시간창 처리량·소요시간·자원 점유. 저널을 **접어서** 만든다(새 계측을 심지 않는다).
120
+ *
121
+ * `twinMetrics`(초당 이벤트 수 = 인프라 부하)와 **다른 것**이다: 이건 "지난 한 시간에 몇 건 처리했고
122
+ * 한 건에 얼마나 걸렸나" 다. 이름이 비슷해 헷갈리기 쉬우므로 설명에 명시한다.
123
+ *
124
+ * 완료된 작업의 **시작 이벤트가 창 앞에 있을 수 있어** 조회 구간을 창보다 앞으로 넓힌다(lookback).
125
+ * 그래도 못 찾은 것은 결과의 `unpaired` 로 드러난다 — 평균에서 조용히 빠지면 지표가 거짓이 된다.
126
+ */
127
+ @Query(returns => ScalarObject, {
128
+ description:
129
+ 'Windowed business KPI folded from the event journal, for one space (every running twin in it, folded together — averaging per-twin percentiles would be wrong) or one twin: throughput (completed tasks / orders), lead / work / wait time distributions (p50·p90), and per-resource busy ratio. Distinct from twinMetrics, which reports infrastructure throughput (events per second). When toTime is omitted the window ends at the twin\'s own last recorded event time, not wall clock — a simulated twin lives on its own clock, so asking for \'the last hour\' in real time would always find nothing. The basis used is reported in window.basis. Pass groupBy (resource | taskKind | node | order | area) to break the same window down by that perspective — per-group numbers use the same rules as the totals, so group counts sum to the total, and groups beyond groupLimit are reported as groups.truncated (how many groups) and groups.truncatedTasks (how much work those groups held) rather than silently dropped. Pass compareTo (previous | yesterday) to fold the same-length window shifted back and get comparison.delta (current minus that window); when the comparison window has no records, comparison.measured is false and no delta is produced rather than reporting zero change. Passing groupBy together with compareTo crosses the two: each group carries prevTasks / deltaTasks / deltaWorkP50Ms, groups absent from the comparison window are flagged isNew (no delta, so growth is not implied), and groups that had work before but none now are returned in groups.disappeared so a stopped area is never silently missing from the table. Area breakdown needs board nodes to carry a parent area; when they do not, groups.note says so instead of implying there was no work. When the journal read hits its row cap, eventsCapped is true and every number is a lower bound — narrow the window and ask again rather than reading the smaller figures as a drop in performance. Completions are counted in the window they complete; starts are looked up before the window, and any completion whose start was not found is reported in `unpaired` rather than silently dropped.'
130
+ })
131
+ async twinKpi(
132
+ @Ctx() context: ResolverContext,
133
+ /* 공간이 사용자의 단위다 — 한 트윈만 보려면 instanceId 를 준다(둘 중 하나 필수). */
134
+ @Arg('spaceId', { nullable: true }) spaceId?: string,
135
+ @Arg('instanceId', { nullable: true }) instanceId?: string,
136
+ /** 창 시작·끝(ISO). 미지정이면 최근 1시간. */
137
+ @Arg('fromTime', { nullable: true }) fromTime?: string,
138
+ @Arg('toTime', { nullable: true }) toTime?: string,
139
+ /** 창 길이(분). fromTime 이 있으면 무시. 기본 60. */
140
+ @Arg('windowMinutes', type => Int, { nullable: true }) windowMinutes?: number,
141
+ /** 추세를 위해 창을 N 등분(1~48). 미지정이면 쪼개지 않는다. */
142
+ @Arg('buckets', type => Int, { nullable: true }) buckets?: number,
143
+ /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60. */
144
+ @Arg('lookbackMinutes', type => Int, { nullable: true }) lookbackMinutes?: number,
145
+ /** 관점 축 — 같은 창을 자원별·작업종류별·도착지점별·오더별·구역별로 쪼갠다. */
146
+ @Arg('groupBy', { nullable: true }) groupBy?: string,
147
+ /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 알린다. */
148
+ @Arg('groupLimit', type => Int, { nullable: true }) groupLimit?: number,
149
+ /** 비교 기준 — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday). */
150
+ @Arg('compareTo', { nullable: true }) compareTo?: string
151
+ ): Promise<any> {
152
+ /* 테넌트 격리 — 트윈 지정이면 소속을 확인한다. 공간 지정이면 대상 해소가 도메인 목록에서
153
+ * 이뤄지므로(computeTwinKpi) 남의 트윈이 섞이지 않는다. 조용한 빈 결과가 아니라 명시 실패. */
154
+ if (instanceId && !TwinEngine.owns(context.state.domain.id, instanceId)) {
155
+ throw new Error(`twin instance not found in this tenant: ${instanceId}`)
156
+ }
157
+ if (!instanceId && !spaceId) throw new Error('either spaceId or instanceId is required')
158
+ /* 창 해소·조회·폴드는 `computeTwinKpi` 가 소유한다 — 같은 규칙을 AI 도구와 공유해야 한다
159
+ * (두 곳에 적으면 화면과 AI 가 다른 숫자를 말한다). */
160
+ return computeTwinKpi({
161
+ domainId: context.state.domain.id,
162
+ instanceId,
163
+ spaceId,
164
+ fromTime,
165
+ toTime,
166
+ windowMinutes,
167
+ buckets,
168
+ lookbackMinutes,
169
+ groupBy: groupBy as any,
170
+ groupLimit,
171
+ compareTo: compareTo as any
172
+ })
173
+ }
174
+
41
175
  @Query(returns => ScalarObject, {
42
176
  nullable: true,
43
177
  description: 'Reconstruct twin state from the durable journal (DB → replay). untilTime (ISO) for time-travel on the shared clock (preferred); untilRevision for the lens-internal counter; omit both for latest.'