@things-factory/headless-twin 10.1.44 → 10.1.45

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.
@@ -10,7 +10,10 @@ export interface TwinEventKeys {
10
10
  /** `'actual'`, `'simulated'`, or absent — absent means not declared, which is not `actual`. */
11
11
  provenance?: string;
12
12
  }
13
- /** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
13
+ /**
14
+ * bizStep 의 짧은 이름 — 자르는 규칙은 계약의 `bizStepName` 하나다(CBV URN 과 사설 이름공간 URL 둘 다).
15
+ * 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거).
16
+ */
14
17
  export declare function bizStepOf(envelope: any): string | undefined;
15
18
  /**
16
19
  * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).
@@ -9,6 +9,7 @@ exports.equipmentIdOf = equipmentIdOf;
9
9
  exports.actionOf = actionOf;
10
10
  exports.provenanceOf = provenanceOf;
11
11
  exports.twinEventKeys = twinEventKeys;
12
+ const ops_contract_1 = require("@operato/ops-contract");
12
13
  const log_js_1 = require("../../engine/log.js");
13
14
  /*
14
15
  * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.
@@ -28,11 +29,13 @@ function clip(v, field) {
28
29
  `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`);
29
30
  return s.slice(0, MAX_KEY);
30
31
  }
31
- /** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
32
+ /**
33
+ * bizStep 의 짧은 이름 — 자르는 규칙은 계약의 `bizStepName` 하나다(CBV URN 과 사설 이름공간 URL 둘 다).
34
+ * 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거).
35
+ */
32
36
  function bizStepOf(envelope) {
33
37
  const d = envelope?.data ?? envelope ?? {};
34
- const tail = String(d.bizStep ?? '').split(':').pop();
35
- return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined;
38
+ return (0, ops_contract_1.bizStepName)(d.bizStep) || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined;
36
39
  }
37
40
  /**
38
41
  * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).
@@ -1 +1 @@
1
- {"version":3,"file":"twin-event-keys.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event-keys.ts"],"names":[],"mappings":"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","sourcesContent":["import { twinWarn } from '../../engine/log.js'\n/*\n * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.\n *\n * ── 왜 승격하는가 ───────────────────────────────────────────────────────────\n * 사용자가 저널에서 실제로 찾는 것은 \"이 팔레트의 이력\", \"이 오더가 어디까지 갔나\", \"이 도크에서\n * 무슨 일이 있었나\" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.\n * things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —\n * 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온\n * 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.\n *\n * 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —\n * 이건 파생 색인이지 새로운 진실이 아니다).\n *\n * ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────\n * 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면\n * 반드시 어긋나고, 어긋난 색인은 \"없는 것처럼 보이는 이벤트\" 를 만든다 — 저널에서 가장 나쁜 결함이다.\n */\n\n/** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */\nexport interface TwinEventKeys {\n action?: string\n bizStep?: string\n epc?: string\n orderId?: string\n bizTransaction?: string\n locationId?: string\n moverId?: string\n /** `'actual'`, `'simulated'`, or absent — absent means not declared, which is not `actual`. */\n provenance?: string\n}\n\n/*\n * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.\n * 넘치는 값이 오면 **알리지 않고 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,\n * 그 사실이 어디에도 안 남으면 아무도 모른다.\n */\nconst MAX_KEY = 255\n\nfunction clip(v: unknown, field: string): string | undefined {\n if (v === undefined || v === null) return undefined\n const s = String(v)\n if (!s) return undefined\n if (s.length <= MAX_KEY) return s\n twinWarn(\n `[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +\n `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`\n )\n return s.slice(0, MAX_KEY)\n}\n\n/** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */\nexport function bizStepOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n const tail = String(d.bizStep ?? '').split(':').pop()\n return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined\n}\n\n/**\n * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).\n *\n * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라\n * 전체를 저장해 두면 끝마디(\"402.2\")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면\n * URN 으로 찾는 경로가 사라진다.\n */\nexport function epcOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined\n}\n\n/**\n * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.\n *\n * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────\n * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다\n * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도\n * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.\n *\n * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)\n * task.status 191,175 행 — 비어 있음 191,175 (100%)\n *\n * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수\n * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —\n * 시점을 어디로 옮겨도 0 이었다.\n *\n * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는\n * 사건부터 조회된다.\n *\n * ── 한 칸에 두 낱말이 들어 있었다 (2026-09-05 실측) ────────────────────────\n * 위 주석이 이미 알고 있었다 — 「EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와 같은\n * 것이 아닐 수 있다」. 그런데 그 둘을 **같은 칸에** 넣고 있었다. 그래서 이렇게 됐다.\n *\n * order.status order-1 내부 오더 id\n * epcis.TransformationEvent urn:epc:id:gdti:9521321.403.1 GS1 거래 식별자\n * epcis.TransactionEvent urn:epc:id:gdti:9521321.403.1\n *\n * 한 오더의 이력이 **색인 안에서 두 쪽으로 갈라진다.** 어느 이름으로 물어도 절반만 나오고,\n * 오류는 나지 않는다. 실측: 어휘가 섞인 행 10,004개.\n *\n * 이것이 실제로 막은 것 — 「이 개체를 어느 레시피로 만들었나」다. 답은 기록되어 있다(오더 사건이\n * `recipeKey` 를 든다, 실측 328,021/328,021 = 100%). 변환 사건은 그 오더를 GDTI 로 가리키는데\n * 오더 사건은 `order-1` 로 적혀 있어 **색인으로 이을 수가 없었다.**\n *\n * 그래서 칸을 갈랐다(사용자 결정 2026-09-05).\n *\n * orderId 내부 오더 id — 읽기 모델의 이음쇠\n * bizTransaction GS1 거래 식별자 — 표준이 말하는 그 거래\n *\n * 오더 사건은 **둘 다** 든다(`order.status` 의 payload 에 `orderId` 와 `bizTransaction` 이 함께\n * 있다). 그래서 EPCIS 사건의 거래 식별자로 오더를 찾을 수 있다 — 이 두 칸이 그 다리다.\n */\nexport function orderOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.orderId ?? undefined\n}\n\n/**\n * **그 사건이 가리키는 거래** — EPCIS `bizTransactionList` 의 첫 항목, 또는 운영 델타가 직접 든 값.\n *\n * 오더가 아니다. 표준이 말하는 거래(PO/SO/생산오더)이고, 한 오더가 여러 거래에 걸릴 수도 있다.\n * 그 구별을 지키려고 `orderId` 와 갈라 두었다 — 합치면 위 주석의 그 일이 다시 난다.\n */\nexport function bizTransactionOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.bizTransactionList?.[0]?.bizTransaction ?? d.bizTransaction ?? undefined\n}\n\n/**\n * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).\n * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서\n * 무엇을 했는지가 위치 축에서 통째로 사라진다.\n */\nexport function locationOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined\n}\n\n/**\n * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.\n *\n * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 \"이 지게차가 오늘 무엇을 했나\" 를\n * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 전수 조회하는 수밖에 없다.\n */\nexport function equipmentIdOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n /*\n * ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ────────────────────────────\n * 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을\n * 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부\n * 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.**\n *\n * 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴\n * 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를\n * 설비 축으로 물을 수 없었다.\n *\n * 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다.\n */\n return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined\n}\n\n/**\n * EPCIS 행위 — `ADD`·`OBSERVE`·`DELETE`. 세 값이 아니면 `undefined`(원천의 잡값을 색인에 넣지 않는다).\n *\n * 이 키는 검색 축이 아니라 **판별식**이다: 되풀어 읽는 스냅샷은 원리적으로 `DELETE` 를 못 낸다.\n * 그래서 「이 트윈이 반출을 한 번이라도 계산했는가」가 진짜 delta 피드의 증거인데, payload 안에 있는\n * 동안에는 5개 드라이버 어디에서도 그 질문을 할 수 없었다. 운영 델타에는 행위가 없다 — 비운다.\n */\nexport function actionOf(envelope: any): string | undefined {\n const a = (envelope?.data ?? envelope ?? {}).action\n return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined\n}\n\n/**\n * **Whether the sender declared this fact as having actually happened** — the envelope's\n * `provenance`, promoted to a column.\n *\n * ── Why it is an indexed column (2026-09-09) ──────────────────────────────\n * The journal held 1,134 rows, all of them generator output, and there was nowhere to ask about\n * it. While the declaration sits inside `payload`, none of the five drivers can answer \"count\n * these without the invented ones\" — the same reason `action` was promoted.\n *\n * ── Only the two declared values are taken ────────────────────────────────\n * Anything that is not `actual` or `simulated` becomes `undefined`. A source's stray value does\n * not reach the index (the discipline in `actionOf`).\n *\n * **`undefined` means \"not declared\", not \"it happened\".** A reader has to distinguish three\n * states — declared simulated, declared actual, not declared. Folding the last two together\n * means a generator that omits the field has its output counted as production.\n */\nexport function provenanceOf(envelope: any): string | undefined {\n /*\n * Read from the envelope only. `data` is where the source system writes, and a source that\n * could declare its own facts actual would make this no longer the connecting side's statement.\n */\n const p = envelope?.provenance\n return p === 'actual' || p === 'simulated' ? p : undefined\n}\n\n/** Every promoted key for one event — the write path calls this function and nothing else. */\nexport function twinEventKeys(envelope: any): TwinEventKeys {\n return {\n action: actionOf(envelope),\n bizStep: clip(bizStepOf(envelope), 'bizStep'),\n epc: clip(epcOf(envelope), 'epc'),\n orderId: clip(orderOf(envelope), 'orderId'),\n bizTransaction: clip(bizTransactionOf(envelope), 'bizTransaction'),\n locationId: clip(locationOf(envelope), 'locationId'),\n moverId: clip(equipmentIdOf(envelope), 'moverId'),\n provenance: provenanceOf(envelope)\n }\n}\n"]}
1
+ {"version":3,"file":"twin-event-keys.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event-keys.ts"],"names":[],"mappings":";;AAyDA,8BAGC;AASD,sBAGC;AA2CD,0BAGC;AAQD,4CAGC;AAOD,gCAGC;AAQD,sCAeC;AASD,4BAGC;AAmBD,oCAOC;AAGD,sCAWC;AAtND,wDAAmD;AAEnD,gDAA8C;AAgC9C;;;;GAIG;AACH,MAAM,OAAO,GAAG,GAAG,CAAA;AAEnB,SAAS,IAAI,CAAC,CAAU,EAAE,KAAa;IACrC,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,SAAS,CAAA;IACnD,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IACnB,IAAI,CAAC,CAAC;QAAE,OAAO,SAAS,CAAA;IACxB,IAAI,CAAC,CAAC,MAAM,IAAI,OAAO;QAAE,OAAO,CAAC,CAAA;IACjC,IAAA,iBAAQ,EACN,qBAAqB,KAAK,YAAY,OAAO,wCAAwC;QACnF,0EAA0E,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAC/F,CAAA;IACD,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,CAAA;AAC5B,CAAC;AAED;;;GAGG;AACH,SAAgB,SAAS,CAAC,QAAa;IACrC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,IAAA,0BAAW,EAAC,CAAC,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,QAAQ,EAAE,SAAS,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,SAAS,CAAA;AACvG,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,KAAK,CAAC,QAAa;IACjC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,IAAI,SAAS,CAAA;AACnF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,SAAgB,OAAO,CAAC,QAAa;IACnC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,OAAO,IAAI,SAAS,CAAA;AAC/B,CAAC;AAED;;;;;GAKG;AACH,SAAgB,gBAAgB,CAAC,QAAa;IAC5C,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAC,EAAE,cAAc,IAAI,CAAC,CAAC,cAAc,IAAI,SAAS,CAAA;AACnF,CAAC;AAED;;;;GAIG;AACH,SAAgB,UAAU,CAAC,QAAa;IACtC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,SAAS,EAAE,EAAE,IAAI,CAAC,CAAC,WAAW,EAAE,EAAE,IAAI,CAAC,CAAC,QAAQ,IAAI,SAAS,CAAA;AACxE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,QAAa;IACzC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,WAAW,IAAI,SAAS,CAAA;AACjE,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,QAAQ,CAAC,QAAa;IACpC,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAC,CAAC,MAAM,CAAA;IACnD,OAAO,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AACzE,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,SAAgB,YAAY,CAAC,QAAa;IACxC;;;OAGG;IACH,MAAM,CAAC,GAAG,QAAQ,EAAE,UAAU,CAAA;IAC9B,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AAC5D,CAAC;AAED,8FAA8F;AAC9F,SAAgB,aAAa,CAAC,QAAa;IACzC,OAAO;QACL,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC;QAC1B,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;QAC7C,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC;QACjC,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;QAC3C,cAAc,EAAE,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;QAClE,UAAU,EAAE,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;QACpD,OAAO,EAAE,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;QACjD,UAAU,EAAE,YAAY,CAAC,QAAQ,CAAC;KACnC,CAAA;AACH,CAAC","sourcesContent":["import { bizStepName } from '@operato/ops-contract'\n\nimport { twinWarn } from '../../engine/log.js'\n/*\n * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.\n *\n * ── 왜 승격하는가 ───────────────────────────────────────────────────────────\n * 사용자가 저널에서 실제로 찾는 것은 \"이 팔레트의 이력\", \"이 오더가 어디까지 갔나\", \"이 도크에서\n * 무슨 일이 있었나\" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.\n * things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —\n * 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온\n * 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.\n *\n * 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —\n * 이건 파생 색인이지 새로운 진실이 아니다).\n *\n * ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────\n * 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면\n * 반드시 어긋나고, 어긋난 색인은 \"없는 것처럼 보이는 이벤트\" 를 만든다 — 저널에서 가장 나쁜 결함이다.\n */\n\n/** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */\nexport interface TwinEventKeys {\n action?: string\n bizStep?: string\n epc?: string\n orderId?: string\n bizTransaction?: string\n locationId?: string\n moverId?: string\n /** `'actual'`, `'simulated'`, or absent — absent means not declared, which is not `actual`. */\n provenance?: string\n}\n\n/*\n * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.\n * 넘치는 값이 오면 **알리지 않고 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,\n * 그 사실이 어디에도 안 남으면 아무도 모른다.\n */\nconst MAX_KEY = 255\n\nfunction clip(v: unknown, field: string): string | undefined {\n if (v === undefined || v === null) return undefined\n const s = String(v)\n if (!s) return undefined\n if (s.length <= MAX_KEY) return s\n twinWarn(\n `[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +\n `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`\n )\n return s.slice(0, MAX_KEY)\n}\n\n/**\n * bizStep 의 짧은 이름 — 자르는 규칙은 계약의 `bizStepName` 하나다(CBV URN 과 사설 이름공간 URL 둘 다).\n * 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거).\n */\nexport function bizStepOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return bizStepName(d.bizStep) || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined\n}\n\n/**\n * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).\n *\n * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라\n * 전체를 저장해 두면 끝마디(\"402.2\")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면\n * URN 으로 찾는 경로가 사라진다.\n */\nexport function epcOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined\n}\n\n/**\n * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.\n *\n * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────\n * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다\n * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도\n * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.\n *\n * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)\n * task.status 191,175 행 — 비어 있음 191,175 (100%)\n *\n * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수\n * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —\n * 시점을 어디로 옮겨도 0 이었다.\n *\n * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는\n * 사건부터 조회된다.\n *\n * ── 한 칸에 두 낱말이 들어 있었다 (2026-09-05 실측) ────────────────────────\n * 위 주석이 이미 알고 있었다 — 「EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와 같은\n * 것이 아닐 수 있다」. 그런데 그 둘을 **같은 칸에** 넣고 있었다. 그래서 이렇게 됐다.\n *\n * order.status order-1 내부 오더 id\n * epcis.TransformationEvent urn:epc:id:gdti:9521321.403.1 GS1 거래 식별자\n * epcis.TransactionEvent urn:epc:id:gdti:9521321.403.1\n *\n * 한 오더의 이력이 **색인 안에서 두 쪽으로 갈라진다.** 어느 이름으로 물어도 절반만 나오고,\n * 오류는 나지 않는다. 실측: 어휘가 섞인 행 10,004개.\n *\n * 이것이 실제로 막은 것 — 「이 개체를 어느 레시피로 만들었나」다. 답은 기록되어 있다(오더 사건이\n * `recipeKey` 를 든다, 실측 328,021/328,021 = 100%). 변환 사건은 그 오더를 GDTI 로 가리키는데\n * 오더 사건은 `order-1` 로 적혀 있어 **색인으로 이을 수가 없었다.**\n *\n * 그래서 칸을 갈랐다(사용자 결정 2026-09-05).\n *\n * orderId 내부 오더 id — 읽기 모델의 이음쇠\n * bizTransaction GS1 거래 식별자 — 표준이 말하는 그 거래\n *\n * 오더 사건은 **둘 다** 든다(`order.status` 의 payload 에 `orderId` 와 `bizTransaction` 이 함께\n * 있다). 그래서 EPCIS 사건의 거래 식별자로 오더를 찾을 수 있다 — 이 두 칸이 그 다리다.\n */\nexport function orderOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.orderId ?? undefined\n}\n\n/**\n * **그 사건이 가리키는 거래** — EPCIS `bizTransactionList` 의 첫 항목, 또는 운영 델타가 직접 든 값.\n *\n * 오더가 아니다. 표준이 말하는 거래(PO/SO/생산오더)이고, 한 오더가 여러 거래에 걸릴 수도 있다.\n * 그 구별을 지키려고 `orderId` 와 갈라 두었다 — 합치면 위 주석의 그 일이 다시 난다.\n */\nexport function bizTransactionOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.bizTransactionList?.[0]?.bizTransaction ?? d.bizTransaction ?? undefined\n}\n\n/**\n * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).\n * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서\n * 무엇을 했는지가 위치 축에서 통째로 사라진다.\n */\nexport function locationOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined\n}\n\n/**\n * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.\n *\n * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 \"이 지게차가 오늘 무엇을 했나\" 를\n * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 전수 조회하는 수밖에 없다.\n */\nexport function equipmentIdOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n /*\n * ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ────────────────────────────\n * 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을\n * 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부\n * 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.**\n *\n * 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴\n * 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를\n * 설비 축으로 물을 수 없었다.\n *\n * 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다.\n */\n return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined\n}\n\n/**\n * EPCIS 행위 — `ADD`·`OBSERVE`·`DELETE`. 세 값이 아니면 `undefined`(원천의 잡값을 색인에 넣지 않는다).\n *\n * 이 키는 검색 축이 아니라 **판별식**이다: 되풀어 읽는 스냅샷은 원리적으로 `DELETE` 를 못 낸다.\n * 그래서 「이 트윈이 반출을 한 번이라도 계산했는가」가 진짜 delta 피드의 증거인데, payload 안에 있는\n * 동안에는 5개 드라이버 어디에서도 그 질문을 할 수 없었다. 운영 델타에는 행위가 없다 — 비운다.\n */\nexport function actionOf(envelope: any): string | undefined {\n const a = (envelope?.data ?? envelope ?? {}).action\n return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined\n}\n\n/**\n * **Whether the sender declared this fact as having actually happened** — the envelope's\n * `provenance`, promoted to a column.\n *\n * ── Why it is an indexed column (2026-09-09) ──────────────────────────────\n * The journal held 1,134 rows, all of them generator output, and there was nowhere to ask about\n * it. While the declaration sits inside `payload`, none of the five drivers can answer \"count\n * these without the invented ones\" — the same reason `action` was promoted.\n *\n * ── Only the two declared values are taken ────────────────────────────────\n * Anything that is not `actual` or `simulated` becomes `undefined`. A source's stray value does\n * not reach the index (the discipline in `actionOf`).\n *\n * **`undefined` means \"not declared\", not \"it happened\".** A reader has to distinguish three\n * states — declared simulated, declared actual, not declared. Folding the last two together\n * means a generator that omits the field has its output counted as production.\n */\nexport function provenanceOf(envelope: any): string | undefined {\n /*\n * Read from the envelope only. `data` is where the source system writes, and a source that\n * could declare its own facts actual would make this no longer the connecting side's statement.\n */\n const p = envelope?.provenance\n return p === 'actual' || p === 'simulated' ? p : undefined\n}\n\n/** Every promoted key for one event — the write path calls this function and nothing else. */\nexport function twinEventKeys(envelope: any): TwinEventKeys {\n return {\n action: actionOf(envelope),\n bizStep: clip(bizStepOf(envelope), 'bizStep'),\n epc: clip(epcOf(envelope), 'epc'),\n orderId: clip(orderOf(envelope), 'orderId'),\n bizTransaction: clip(bizTransactionOf(envelope), 'bizTransaction'),\n locationId: clip(locationOf(envelope), 'locationId'),\n moverId: clip(equipmentIdOf(envelope), 'moverId'),\n provenance: provenanceOf(envelope)\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.1.44",
3
+ "version": "10.1.45",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh <heartyoh@hatiolab.com>",
@@ -27,13 +27,13 @@
27
27
  "clean:shared": "rm -rf dist-shared tsconfig.shared.tsbuildinfo"
28
28
  },
29
29
  "dependencies": {
30
- "@operato/ops-contract": "^0.9.37",
31
- "@operato/twin-kernel": "^0.11.26",
30
+ "@operato/ops-contract": "^0.9.38",
31
+ "@operato/twin-kernel": "^0.11.27",
32
32
  "@things-factory/auth-base": "^10.1.44",
33
33
  "@things-factory/cache-service": "^10.1.44",
34
34
  "@things-factory/env": "^10.1.20",
35
35
  "@things-factory/ingest": "^10.1.44",
36
36
  "@things-factory/shell": "^10.1.44"
37
37
  },
38
- "gitHead": "7c95c4e75e155e51c714409e865d96f317232c23"
38
+ "gitHead": "d72e1fd1285797fcd7839d97a18e31f5bf076ae3"
39
39
  }
@@ -1,3 +1,5 @@
1
+ import { bizStepName } from '@operato/ops-contract'
2
+
1
3
  import { twinWarn } from '../../engine/log.js'
2
4
  /*
3
5
  * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.
@@ -49,11 +51,13 @@ function clip(v: unknown, field: string): string | undefined {
49
51
  return s.slice(0, MAX_KEY)
50
52
  }
51
53
 
52
- /** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
54
+ /**
55
+ * bizStep 의 짧은 이름 — 자르는 규칙은 계약의 `bizStepName` 하나다(CBV URN 과 사설 이름공간 URL 둘 다).
56
+ * 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거).
57
+ */
53
58
  export function bizStepOf(envelope: any): string | undefined {
54
59
  const d = envelope?.data ?? envelope ?? {}
55
- const tail = String(d.bizStep ?? '').split(':').pop()
56
- return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined
60
+ return bizStepName(d.bizStep) || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined
57
61
  }
58
62
 
59
63
  /**
@@ -63,6 +63,13 @@ test('bizStep 이 없으면 이벤트 타입에서 유추한다 — 운영 델
63
63
  assert.equal(bizStepOf({ eventType: 'epcis.AggregationEvent', data: {} }), 'AggregationEvent')
64
64
  })
65
65
 
66
+ test('a private business step is indexed by its short name, like a CBV one', () => {
67
+ /* `split(':')` on `https://hatiolab.com/voc/bizstep/consuming` kept `//hatiolab.com/voc/bizstep/consuming` — a journal
68
+ search for `consuming` found nothing. The rule is the contract's bizStepName. */
69
+ assert.equal(bizStepOf({ data: { bizStep: 'https://hatiolab.com/voc/bizstep/consuming' } }), 'consuming')
70
+ assert.equal(bizStepOf({ data: { bizStep: 'urn:epcglobal:cbv:bizstep:shipping' } }), 'shipping')
71
+ })
72
+
66
73
  test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목으로 쓴다', () => {
67
74
  assert.equal(epcOf({ data: { parentID: 'urn:epc:id:sscc:0614141.1234567890' } }), 'urn:epc:id:sscc:0614141.1234567890')
68
75
  assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*')