import { bizStepName } from '@operato/ops-contract' import { twinWarn } from '../../engine/log.js' /* * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다. * * ── 왜 승격하는가 ─────────────────────────────────────────────────────────── * 사용자가 저널에서 실제로 찾는 것은 "이 팔레트의 이력", "이 오더가 어디까지 갔나", "이 도크에서 * 무슨 일이 있었나" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다. * things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 — * 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온 * 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다. * * 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload — * 이건 파생 색인이지 새로운 진실이 아니다). * * ── 왜 여기(순수 모듈)인가 ────────────────────────────────────────────────── * 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면 * 반드시 어긋나고, 어긋난 색인은 "없는 것처럼 보이는 이벤트" 를 만든다 — 저널에서 가장 나쁜 결함이다. */ /** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */ export interface TwinEventKeys { action?: string bizStep?: string epc?: string orderId?: string bizTransaction?: string locationId?: string moverId?: string /** `'actual'`, `'simulated'`, or absent — absent means not declared, which is not `actual`. */ provenance?: string } /* * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다. * 넘치는 값이 오면 **알리지 않고 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데, * 그 사실이 어디에도 안 남으면 아무도 모른다. */ const MAX_KEY = 255 function clip(v: unknown, field: string): string | undefined { if (v === undefined || v === null) return undefined const s = String(v) if (!s) return undefined if (s.length <= MAX_KEY) return s twinWarn( `[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` + `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)` ) return s.slice(0, MAX_KEY) } /** * bizStep 의 짧은 이름 — 자르는 규칙은 계약의 `bizStepName` 하나다(CBV URN 과 사설 이름공간 URL 둘 다). * 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */ export function bizStepOf(envelope: any): string | undefined { const d = envelope?.data ?? envelope ?? {} return bizStepName(d.bizStep) || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined } /** * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다). * * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라 * 전체를 저장해 두면 끝마디("402.2")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면 * URN 으로 찾는 경로가 사라진다. */ export function epcOf(envelope: any): string | undefined { const d = envelope?.data ?? envelope ?? {} return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined } /** * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`. * * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ────────────────────── * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다 * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도 * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다. * * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%) * task.status 191,175 행 — 비어 있음 191,175 (100%) * * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수 * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 — * 시점을 어디로 옮겨도 0 이었다. * * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는 * 사건부터 조회된다. * * ── 한 칸에 두 낱말이 들어 있었다 (2026-09-05 실측) ──────────────────────── * 위 주석이 이미 알고 있었다 — 「EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와 같은 * 것이 아닐 수 있다」. 그런데 그 둘을 **같은 칸에** 넣고 있었다. 그래서 이렇게 됐다. * * order.status order-1 내부 오더 id * epcis.TransformationEvent urn:epc:id:gdti:9521321.403.1 GS1 거래 식별자 * epcis.TransactionEvent urn:epc:id:gdti:9521321.403.1 * * 한 오더의 이력이 **색인 안에서 두 쪽으로 갈라진다.** 어느 이름으로 물어도 절반만 나오고, * 오류는 나지 않는다. 실측: 어휘가 섞인 행 10,004개. * * 이것이 실제로 막은 것 — 「이 개체를 어느 레시피로 만들었나」다. 답은 기록되어 있다(오더 사건이 * `recipeKey` 를 든다, 실측 328,021/328,021 = 100%). 변환 사건은 그 오더를 GDTI 로 가리키는데 * 오더 사건은 `order-1` 로 적혀 있어 **색인으로 이을 수가 없었다.** * * 그래서 칸을 갈랐다(사용자 결정 2026-09-05). * * orderId 내부 오더 id — 읽기 모델의 이음쇠 * bizTransaction GS1 거래 식별자 — 표준이 말하는 그 거래 * * 오더 사건은 **둘 다** 든다(`order.status` 의 payload 에 `orderId` 와 `bizTransaction` 이 함께 * 있다). 그래서 EPCIS 사건의 거래 식별자로 오더를 찾을 수 있다 — 이 두 칸이 그 다리다. */ export function orderOf(envelope: any): string | undefined { const d = envelope?.data ?? envelope ?? {} return d.orderId ?? undefined } /** * **그 사건이 가리키는 거래** — EPCIS `bizTransactionList` 의 첫 항목, 또는 운영 델타가 직접 든 값. * * 오더가 아니다. 표준이 말하는 거래(PO/SO/생산오더)이고, 한 오더가 여러 거래에 걸릴 수도 있다. * 그 구별을 지키려고 `orderId` 와 갈라 두었다 — 합치면 위 주석의 그 일이 다시 난다. */ export function bizTransactionOf(envelope: any): string | undefined { const d = envelope?.data ?? envelope ?? {} return d.bizTransactionList?.[0]?.bizTransaction ?? d.bizTransaction ?? undefined } /** * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation). * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서 * 무엇을 했는지가 위치 축에서 통째로 사라진다. */ export function locationOf(envelope: any): string | undefined { const d = envelope?.data ?? envelope ?? {} return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined } /** * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축. * * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 "이 지게차가 오늘 무엇을 했나" 를 * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 전수 조회하는 수밖에 없다. */ export function equipmentIdOf(envelope: any): string | undefined { const d = envelope?.data ?? envelope ?? {} /* * ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ──────────────────────────── * 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을 * 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부 * 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.** * * 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴 * 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를 * 설비 축으로 물을 수 없었다. * * 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다. */ return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined } /** * EPCIS 행위 — `ADD`·`OBSERVE`·`DELETE`. 세 값이 아니면 `undefined`(원천의 잡값을 색인에 넣지 않는다). * * 이 키는 검색 축이 아니라 **판별식**이다: 되풀어 읽는 스냅샷은 원리적으로 `DELETE` 를 못 낸다. * 그래서 「이 트윈이 반출을 한 번이라도 계산했는가」가 진짜 delta 피드의 증거인데, payload 안에 있는 * 동안에는 5개 드라이버 어디에서도 그 질문을 할 수 없었다. 운영 델타에는 행위가 없다 — 비운다. */ export function actionOf(envelope: any): string | undefined { const a = (envelope?.data ?? envelope ?? {}).action return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined } /** * **Whether the sender declared this fact as having actually happened** — the envelope's * `provenance`, promoted to a column. * * ── Why it is an indexed column (2026-09-09) ────────────────────────────── * The journal held 1,134 rows, all of them generator output, and there was nowhere to ask about * it. While the declaration sits inside `payload`, none of the five drivers can answer "count * these without the invented ones" — the same reason `action` was promoted. * * ── Only the two declared values are taken ──────────────────────────────── * Anything that is not `actual` or `simulated` becomes `undefined`. A source's stray value does * not reach the index (the discipline in `actionOf`). * * **`undefined` means "not declared", not "it happened".** A reader has to distinguish three * states — declared simulated, declared actual, not declared. Folding the last two together * means a generator that omits the field has its output counted as production. */ export function provenanceOf(envelope: any): string | undefined { /* * Read from the envelope only. `data` is where the source system writes, and a source that * could declare its own facts actual would make this no longer the connecting side's statement. */ const p = envelope?.provenance return p === 'actual' || p === 'simulated' ? p : undefined } /** Every promoted key for one event — the write path calls this function and nothing else. */ export function twinEventKeys(envelope: any): TwinEventKeys { return { action: actionOf(envelope), bizStep: clip(bizStepOf(envelope), 'bizStep'), epc: clip(epcOf(envelope), 'epc'), orderId: clip(orderOf(envelope), 'orderId'), bizTransaction: clip(bizTransactionOf(envelope), 'bizTransaction'), locationId: clip(locationOf(envelope), 'locationId'), moverId: clip(equipmentIdOf(envelope), 'moverId'), provenance: provenanceOf(envelope) } }