import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { ObjectType, Field, ID, Int } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' /* * TwinEvent — append-only 트윈 이벤트 저널(EPCIS 이벤트 + 운영 델타). * 커널 EventJournal 의 영속 대응 — 상태=이벤트열의 함수(재부팅 시 DB→replay 재구성). * append-only 이므로 updater/deletedAt 없음(이력 CRUD 가 아니라 불변 이벤트 스트림). * payload 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통, DB-specific JSON 타입 금지). * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.) */ /* * ── 인덱스 설계 (2026-07-31) ──────────────────────────────────────────────── * 이 표는 **인제스트 경로의 뜨거운 append-only 테이블**이다. 인덱스 하나하나가 쓰기 증폭이므로 * "있으면 좋을" 인덱스를 붙이지 않는다. 실제 질의 패턴에 대응하는 것만 둔다. * * ix_0 (domain, instanceId, revision) 원장 기본 정렬·커서 페이징·replay(ASC 주사) * ix_1 (domain, instanceId, eventTime) 시각 커서(untilTime)·시간창 KPI — 거의 모든 조회가 탄다 * ix_2 (domain, instanceId, eventType, revision) 타입 필터 + 정렬 동시 충족(스케줄 화면 task/equipment) * ix_3 (domain, instanceId, epc) "이 물건의 이력" — Entity360 의 본질 질문 * ix_4 (domain, instanceId, orderId) "이 오더가 어디까지 갔나" * ix_9 (domain, instanceId, bizTransaction) "이 거래에 무슨 일이 있었나" — EPCIS↔오더의 다리 * * bizStep·locationId·moverId 는 컬럼만 두고 인덱스는 두지 않는다 — 한 트윈 안에서 카디널리티가 * 낮아(업무단계 몇 개, 위치 수백, 설비 수십) (domain,instanceId) 로 이미 좁혀진 뒤의 잔여 필터로 * 충분하고, 뜨거운 표에 인덱스를 더 얹을 가치가 없다. 저널 하나가 아주 커져서 이 축들의 조회가 * 느려지면 그때 측정을 근거로 인덱스를 추가할 일이지, 지레 얹어 쓰기를 무겁게 할 일은 아니다. */ @Entity() @Index('ix_twin_event_0', (e: TwinEvent) => [e.domain, e.instanceId, e.revision], { unique: false }) @Index('ix_twin_event_1', (e: TwinEvent) => [e.domain, e.instanceId, e.eventTime], { unique: false }) @Index('ix_twin_event_2', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.revision], { unique: false }) @Index('ix_twin_event_3', (e: TwinEvent) => [e.domain, e.instanceId, e.epc], { unique: false }) @Index('ix_twin_event_4', (e: TwinEvent) => [e.domain, e.instanceId, e.orderId], { unique: false }) /* ix_5 — "이 지시가 무엇을 일으켰나". 감사 기록의 commandId 로 저널을 다시 계산하는 축. */ @Index('ix_twin_event_5', (e: TwinEvent) => [e.domain, e.correlationId], { unique: false }) /* ix_6 — "반출을 한 번이라도 계산했나". 없으면 그 질문이 인스턴스 저널 전량 주사가 된다. */ @Index('ix_twin_event_6', (e: TwinEvent) => [e.domain, e.instanceId, e.action], { unique: false }) /* * ix_7 — **"이 종류의 사실을 시각순으로"** (2026-08-25 실측으로 추가) * * 이 축이 없어서 공정 타임라인이 22초였다. 종류로 거르고 시각으로 정렬하는 조회가 갈 곳이 없었다: * * ix_2 (…, eventType, revision) 종류는 걸러지고 정렬이 **적힌 순서**다 → 4월 사실을 최근으로 그렸다 * ix_1 (…, eventTime) 정렬은 맞고 **종류를 못 거른다** → 행마다 테이블을 열어 확인한다 * * 뒤쪽으로 걸으면 그 트윈에서 찾는 종류가 멈춘 시점까지의 모든 행을 읽고 버린다. 승화푸드에서 작업 * 사실은 8월 22일에 멈춰 있고 그 뒤로 리비전이 110만 늘었으므로, 200건에 닿기까지 110만 행을 열었다. * * 위 주석이 「측정을 근거로 추가할 일이지 지레 얹을 일은 아니다」라고 적어 두었다. 그 측정이 나왔다. */ @Index('ix_twin_event_7', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.eventTime], { unique: false }) /* * ix_8 — **"이 트윈의 오래된 기록"** (2026-08-27 실측으로 추가) * * 보관 기간 정리가 `created_at` 으로 자르는데(보관은 저장 나이의 문제다) 그 축에 인덱스가 없었다. * 그래서 정리가 매 배치마다 그 트윈의 저널을 전량 주사했다. * * hatio-mx2 의 7일 밖 행을 세기 208초 ← 1,029만 행을 전부 읽는다(7일 밖은 2,446건뿐이다) * revision 으로 한 행 짚기 0.004초 * * 정리는 한 창에 2초만 쓰도록 예산이 걸려 있어서, 그 2초를 주사에 다 쓰고 44건만 지우고 멈췄다. */ @Index('ix_twin_event_8', (e: TwinEvent) => [e.domain, e.instanceId, e.createdAt], { unique: false }) /* ix_9 — "이 거래에 무슨 일이 있었나". EPCIS 사건에서 오더로 건너가는 다리(§`bizTransaction`). */ @Index('ix_twin_event_9', (e: TwinEvent) => [e.domain, e.instanceId, e.bizTransaction], { unique: false }) @ObjectType({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' }) export class TwinEvent { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the event record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinEvent) => e.domain) domainId?: string /* * 이 사실을 낸 트윈. **빌 수 없다** — 저널 행을 만드는 곳은 `journalRow` 하나뿐이고 거기서 * 필수 인자로 받는다. nullable 로 두면 읽는 쪽이 매번 `?? ''` 를 달게 되고, 그 방어가 실제로는 * 일어나지 않는 경우를 대비하느라 "빈 인스턴스의 이벤트" 라는 없는 개념을 코드에 남긴다. */ @Column() @Field({ description: 'Twin runtime instance id that emitted the event.' }) instanceId: string /* 커널 봉투의 필수 필드(`CanonicalEnvelope.eventType`)라 빌 수 없다 — 계약을 그대로 옮긴다. */ @Column() @Field({ description: 'Event type: epcis.* | task.status | equipment.status | order.status.' }) eventType: string @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'Monotonic state revision at which the event was emitted.' }) revision?: number /** * 이 사실이 **어느 공장에서** 일어났나 — `TwinStructure.rev`. * * 이것이 없던 시절에는 구조가 바뀌면 저널을 통째로 지우는 것이 유일한 길이었다(안 지우면 옛 * 이벤트를 새 공장에 대고 계산해 이력이 거짓말을 한다). 이 한 칸이 셋째 길을 연다 — 재생이 * 구조가 바뀌는 지점에서 전환한 뒤 이어서 계산한다. * * **비어 있을 수 있다**: 컬럼이 생기기 전에 쓰인 행이다. 그 행들은 **가장 오래된 리비전**에 * 속한다(0 을 채워 넣지 않는다 — 0 은 "모름" 이 아니라 유효한 번호처럼 보인다). */ @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'Structure revision (TwinStructure.rev) in force when this event happened. Absent on rows written before structure revisions existed; those belong to the oldest revision.' }) structureRev?: number /* * 이 사실이 일어난 시각(트윈의 시계). **날짜 타입이다.** * * 예전에는 문자열이었다. 읽을 때마다 `Date.parse` 했고, 정렬은 UTC ISO 만 들어온다는 전제 위에서 * 사전식 비교가 우연히 맞았다 — `+09:00` 오프셋 표기가 하나 섞이면 알리지 않고 순서가 틀린다. * 그 전제는 컬럼 어디에도 적혀 있지 않았다. * * **타입 이름을 적지 않는다** (2026-08-19 Postgres 부팅 실패로 고침). * * `datetime` 을 적었더니 Postgres 가 부팅 중에 거절했다(`DataTypeNotSupportedError`) — 그 이름은 * sqlite·MySQL 의 것이고 Postgres 는 `timestamp` 만 안다. 거꾸로 `timestamp` 는 sqlite 가 모른다: * **다섯 드라이버에 다 통하는 이름이 아예 없다.** * * 그래서 이름을 고르는 일을 TypeORM 에 맡긴다. `emitDecoratorMetadata` 가 켜져 있으므로 이 속성의 * TS 타입(`Date`)에서 드라이버별 타입을 스스로 고른다(Postgres `timestamp` · sqlite·MySQL `datetime`). * 이 레포의 다중 DB 규약이 요구하는 것이 바로 그것이다 — 드라이버 방언을 코드에 적지 않는다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Time at which the fact happened on the twin clock.' }) eventTime?: Date /* * ── 승격된 검색 축 ──────────────────────────────────────────────────────── * payload 안에 있던 값을 인제스트 시점에 꺼내 실컬럼으로 둔다(`twin-event-keys.ts` 가 단독 소유). * payload 가 여전히 정본이고 이것들은 **파생 색인**이다 — 새로운 진실이 아니라 찾을 수 있게 하는 장치. * simple-json(TEXT) 안의 값은 5개 DB 드라이버 공통으로 거를 방법이 없어서(멀티DB 호환 규칙상 * DB별 JSON 연산자 금지) 승격 외의 선택지가 없다. * 길이 상한 255 는 GS1 식별자 규격 대비 충분하며, 넘치는 값은 알리지 않고 잘리지 않고 경고를 남긴다. * * ── 축을 새로 추가할 때 ────────────────────────────────────────────────── * 새 승격 컬럼을 만들면 **이미 쌓인 행은 비어 있다.** 그 행들은 새 축으로 검색되지 않고, * 사용자에게는 "과거가 없는 것" 으로 보인다(알리지 않고 빠진 데이터). * * 예전에는 기동 훅이 매번 테이블을 살펴 채웠다. 그건 두 가지로 틀렸다 — 저널이 커질수록 부팅이 * 느려지고, "bizStep 이 비었으면 옛 행" 이라는 전제를 코드에 고정해 그 전제가 깨지는 순간 * (감사 행처럼 업무 단계가 없는 행) 매 부팅마다 헛돌았다. 그래서 걷어냈다(git: backfill-keys.ts). * * 채우는 일은 **마이그레이션 한 번**이 맞다(`yarn migration`). 그 컬럼에 맞는 것을 그때 쓴다 — * 범용 스캐너를 되살리지 말 것. 개발 데이타라면 그냥 버리는 것도 답이다. */ /** * 이 사실을 일으킨 **지시** — 커널이 커맨드에서 이어 준 상관값(기본은 commandId). * * 이것이 감사 기록(TwinAuditEvent.commandId)과 저널을 잇는 유일한 다리다. 없으면 승인 기록은 * "사람이 허락했다" 까지이고, 그 뒤 공장이 실제로 어떻게 움직였는지와 이어지지 않는다. * * **대부분의 행은 비어 있다** — 커맨드가 아니라 시뮬 시간이 낳은 사실이기 때문이다. 그것이 정상이고, * 비어 있다고 "덜 채워진 옛 행" 으로 보면 안 된다(같은 오해를 bizStep 에서 이미 한 번 했다). */ @Column({ length: 255, nullable: true }) @Field({ nullable: true, description: 'Id of the command that caused this fact (kernel correlation id; defaults to the commandId). Empty for facts that simulation time produced rather than a command.' }) correlationId?: string /* 옛 행은 비어 있다 — 승격 전에 기록된 것이라 **모른다**(0 이나 OBSERVE 로 위장하지 않는다). */ @Column({ length: 16, nullable: true }) @Field({ nullable: true, description: 'EPCIS action (ADD / OBSERVE / DELETE), promoted from the payload. Empty for operational deltas, and for rows journaled before this column existed.' }) action?: string @Column({ length: 255, nullable: true }) @Field({ nullable: true, description: 'Business step (CBV bizStep tail), promoted from the payload for indexed filtering.' }) bizStep?: string @Column({ length: 255, nullable: true }) @Field({ nullable: true, description: 'Item identifier (EPC / EPC class / parent id), promoted from the payload for indexed lookup of one item history.' }) epc?: string @Column({ length: 255, nullable: true }) @Field({ nullable: true, description: 'Internal order identifier, promoted from the payload for indexed lookup of one order history.' }) orderId?: string /* * **거래 식별자는 오더 식별자와 다른 것이다.** * * 전에는 둘이 한 칸(`orderId`)에 들어갔다. 그래서 한 오더의 이력이 색인 안에서 두 쪽으로 * 갈라졌다 — 운영 델타는 `order-1`, EPCIS 사건은 `urn:epc:id:gdti:…` 로 적혀 어느 이름으로 * 물어도 절반만 나왔다(실측 2026-09-05: 어휘가 섞인 행 10,004개, 오류는 나지 않음). * * 오더 사건은 두 값을 다 들고 있다. 그래서 이 칸이 **EPCIS 사건에서 오더로 건너가는 다리**가 * 된다 — 「이 개체를 어느 레시피로 만들었나」가 그 다리로 답해진다. */ @Column({ length: 255, nullable: true }) @Field({ nullable: true, description: 'Business transaction identifier (PO / SO / production order), promoted from the payload for indexed lookup of one transaction.' }) bizTransaction?: string @Column({ length: 255, nullable: true }) @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.' }) locationId?: string @Column({ length: 255, nullable: true }) @Field({ nullable: true, description: 'Equipment identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server. The column keeps the legacy name `moverId`: the journal is append-only history and mixing two names for the same fact across time is worse than an outdated name. Renaming belongs with event-schema versioning.' }) moverId?: string /** * **Whether the sender declared this fact as having actually happened** — the envelope's * `provenance`, promoted here. * * ── Why a column (measured 2026-09-09) ────────────────────────────────── * This table held 1,134 rows, all of them generator output, and **there was nowhere to ask * about it**: * * ``` * select count(*) from twin_events 1134 * select count(*) where payload like '%SIM-%' 1134 * select count(*) where order_id like 'WO-SIM-%' 558 <- half of them * ``` * * The only signal was the sender's naming convention (`SIM-`), and the column that convention * reaches was empty on half the rows. While it sits inside `payload`, none of the five drivers * can answer "count these without the invented ones" — the same reason `action` was promoted. * * ── Three states, kept apart ──────────────────────────────────────────── * ``` * 'simulated' the sender said it was invented * 'actual' the sender said it happened * null **not declared** — which is not `actual` * ``` * * Folding the last two together means a generator that omits the declaration has its output * counted as production, and nothing says so. Hence no default: an absent declaration is not * disguised as a valid-looking value (the discipline of `structureRev` and `lastWarningCount`). * * ── No index ──────────────────────────────────────────────────────────── * Three possible values is low cardinality within one twin — the judgement already made for * `bizStep` and `locationId`. Performance folds arrive already narrowed by domain, instance and * time, so filtering after that is cheap. */ @Column({ length: 16, nullable: true }) @Field({ nullable: true, description: 'Whether the sending side declared this fact as actually having happened. Either "actual" or "simulated"; null means it was not declared, which is not the same as actual — a generator that omits it must not be counted as production.' }) provenance?: string @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' }) payload?: any @CreateDateColumn() @Field({ nullable: true, description: 'Wall-clock timestamp when the record was persisted.' }) createdAt?: Date }