@things-factory/headless-twin 10.0.14 → 10.0.15

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 (36) hide show
  1. package/dist-server/engine/canonical-ingest.js +30 -0
  2. package/dist-server/engine/canonical-ingest.js.map +1 -1
  3. package/dist-server/engine/ingest-health.d.ts +35 -0
  4. package/dist-server/engine/ingest-health.js +45 -0
  5. package/dist-server/engine/ingest-health.js.map +1 -1
  6. package/dist-server/engine/twin-engine.d.ts +7 -0
  7. package/dist-server/engine/twin-engine.js +19 -0
  8. package/dist-server/engine/twin-engine.js.map +1 -1
  9. package/dist-server/service/reference/reference-adapter.d.ts +25 -0
  10. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  11. package/dist-server/service/reference/reference-live.js +12 -0
  12. package/dist-server/service/reference/reference-live.js.map +1 -1
  13. package/dist-server/service/twin-event/twin-event-keys.d.ts +21 -1
  14. package/dist-server/service/twin-event/twin-event-keys.js +35 -3
  15. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  16. package/dist-server/service/twin-model/epcis-coverage.js +5 -1
  17. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  18. package/dist-server/service/twin-model/isa95-coverage.d.ts +10 -0
  19. package/dist-server/service/twin-model/isa95-coverage.js +3 -3
  20. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  21. package/dist-server/service/twin-model/twin-model-query.js +71 -3
  22. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  23. package/package.json +3 -3
  24. package/server/engine/canonical-ingest.ts +30 -0
  25. package/server/engine/ingest-health.ts +65 -0
  26. package/server/engine/twin-engine.ts +20 -0
  27. package/server/service/reference/reference-adapter.ts +22 -0
  28. package/server/service/reference/reference-live.ts +12 -0
  29. package/server/service/twin-event/twin-event-keys.ts +35 -3
  30. package/server/service/twin-model/epcis-coverage.ts +5 -1
  31. package/server/service/twin-model/isa95-coverage.ts +13 -3
  32. package/server/service/twin-model/twin-model-query.ts +71 -3
  33. package/test/standard-coverage.test.ts +44 -0
  34. package/test/twin-event-keys.test.ts +48 -2
  35. package/test/withheld-door.test.ts +95 -0
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -168,6 +168,31 @@ export interface LiveFeedContinuity {
168
168
  reason: string;
169
169
  stream?: string;
170
170
  }) => void;
171
+ /**
172
+ * **원본에 있는데 세우지 않은 것을 알린다** — 이유와 수.
173
+ *
174
+ * ── 왜 이 통로가 필요한가 (2026-08-24) ─────────────────────────────────────
175
+ * 어댑터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**(입자가 안 맞는다 · 받으면 상태가
176
+ * 거짓이 된다 · 커널 어휘로 옮길 수 없다). 그때 어댑터는 경고를 냈는데, 그 경고가 **프로비저닝
177
+ * 화면에서 한 번 스쳐 지나갈 뿐**이었다 — 그 뒤로는 어디에서도 볼 수 없었다.
178
+ *
179
+ * 그래서 사용자가 「원본보다 이것이 적다」를 물으면 답이 어디에도 없었다. 그 사실은 한 번의 사건이
180
+ * 아니라 **상태의 성질**이다: 세우지 않은 것은 **지금도** 트윈에 없다.
181
+ *
182
+ * **주기마다 불러도 된다** — 장부가 이유로 묶어 세고 마지막 수로 덮는다(누적하지 않는다). 그래서
183
+ * 「지금 세우지 않은 것이 8건」이 8로 남고 800으로 부풀지 않는다.
184
+ *
185
+ * **풀리면 부르지 않는 것으로 끝나지 않는다** — 이유가 사라졌으면 `count: 0` 으로 부르는 것이 아니라
186
+ * 그 이유를 더 이상 보내지 않으면 된다… 가 **아니다.** 장부는 마지막 값을 들고 있으므로, 풀린 것을
187
+ * 알리려면 그 이유로 `count: 0` 을 한 번 보내라(그때 줄이 사라진다). 조용히 그치면 낡은 수가 남는다.
188
+ *
189
+ * 이유는 **자유 문자열**이다 — 무엇을 왜 안 받는지는 원본마다 다르고, 계약이 목록을 닫으면 새 원본을
190
+ * 붙일 때마다 계약을 고치게 된다(흐름 열쇠와 같은 규율). 사람이 읽을 문장으로 적어라.
191
+ */
192
+ onWithheld?: (info: {
193
+ reason: string;
194
+ count: number;
195
+ }) => void;
171
196
  }
172
197
  export interface ReferenceAdapter {
173
198
  /** 레지스트리 키 (예: 'virtual' | 'sap-ewm' | 'custom-rest'). */
@@ -1 +1 @@
1
- {"version":3,"file":"reference-adapter.js","sourceRoot":"","sources":["../../../server/service/reference/reference-adapter.ts"],"names":[],"mappings":";;AAwUA,kCAgBC;AAED,wCAOC;AAKD,kDAEC;AACD,gCAEC;AACD,4CAEC;AASD,oCAEC;AA1DD;;;;;;;;GAQG;AACH,SAAgB,WAAW,CAAC,OAAqC;IAM/D,MAAM,CAAC,GAAG,OAAO,EAAE,SAAS,CAAA;IAC5B,IAAI,CAAC,CAAC,EAAE,KAAK;QAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAA;IAC1C,MAAM,OAAO,GAAG,CAAC,CAAC,eAAe,EAAE,IAAI,EAAE,CAAA;IACzC,IAAI,CAAC,CAAC,CAAC,KAAK,KAAK,UAAU,IAAI,CAAC,CAAC,KAAK,KAAK,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;QAClE,OAAO;YACL,KAAK,EAAE,SAAS;YAChB,UAAU,EAAE,WAAW,CAAC,CAAC,KAAK,iDAAiD;SAChF,CAAA;IACH,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAA;AAC7E,CAAC;AAED,SAAgB,cAAc,CAAC,OAAqC;IAClE,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAA;IACvB,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,CAAA;IACpD,MAAM,GAAG,GAA0B,EAAE,CAAA;IACrC,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,OAAO,OAAO,CAAC,YAAY,KAAK,UAAU;QAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxF,IAAI,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,OAAO,OAAO,CAAC,OAAO,KAAK,UAAU;QAAE,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;IACzF,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,wEAAwE;AACxE,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA4B,CAAA;AAEpD,SAAgB,mBAAmB,CAAC,OAAyB;IAC3D,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;AACrC,CAAC;AACD,SAAgB,UAAU,CAAC,IAAY;IACrC,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;AAC3B,CAAC;AACD,SAAgB,gBAAgB;IAC9B,OAAO,CAAC,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;AAC7B,CAAC;AACD,kCAAkC;AAClC;;;;;;GAMG;AACH,SAAgB,YAAY;IAC1B,OAAO,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CAAA;AACxG,CAAC","sourcesContent":["import type { ReferenceStep } from './reference-progress.js'\nimport type { ReferenceMaster } from './reference-master.js'\n\n/*\n * 레퍼런스 어댑터 계약 (reference-management P1) — 정적 마스터 등록(`registerReference`)을\n * \"어댑터 타입\" 등록으로 일반화한다. 레퍼런스 = 데이터(TwinReference 행), 어댑터 = 동작.\n * inbound 전용(구조·상태 유입). 아웃바운드 액추에이션은 command-routing(별도 ActuationAdapter) 소유.\n * seam: openLiveFeed 레코드 → 커널 face2-adapter.ingest → CanonicalEnvelope 까지가 여기,\n * 이후 projector 구동은 face2-inbound-live 소유.\n * 설계 SoT: operato-twin/design/plans/reference-management.md §2.2·§4.2.\n */\n\n/** 연결 설정(어댑터별 스키마) — 엔드포인트·자격·파라미터. */\nexport type ConnectionConfig = Record<string, any>\n\n/** discoverSites 가 반환하는 사이트/공장 서술자 — N 발견(§2.2). 1:1 은 길이 1 배열. */\nexport interface SiteDescriptor {\n /** 안정적 사이트 정체성 키(소스의 plant code 등) — 공간 정합 1차 키. */\n siteId: string\n name?: string\n /** 사이트별 시스템(소스가 시스템을 넘나들 때). 없으면 레퍼런스 system 사용. */\n system?: string\n geo?: { lat: number; lon: number }\n hint?: any\n}\n\n/** 연결 폼 필드 서술 — UI 가 이 스키마로 connectionConfig 입력 폼을 렌더한다(어댑터가 스키마 제공). */\nexport interface AdapterConfigField {\n key: string\n label: string\n placeholder?: string\n required?: boolean\n secret?: boolean // 비밀번호/토큰 — 마스킹 입력\n multiline?: boolean // JSON/매핑 등 여러 줄 입력(textarea)\n help?: string // 필드 아래 도움말\n default?: string // 폼 프리필 기본값(예시 매핑/URL 등)\n}\n/** 커넥터 메타데이터 — picker·연결 폼용 표시 정보. */\nexport interface AdapterMeta {\n label?: string\n domain?: string // 'wms' | 'yms' | 'mes' (분류)\n available?: boolean // false = 준비 중(picker 비활성)\n configSchema?: AdapterConfigField[]\n ootbNote?: string // OOTB 매핑 안내 문구\n /*\n * 근거 수준(정직성 1급 표기 — \"실제 API 명세에 근거하는가\"를 UI·피치가 오해 없이 보이게).\n * 'vendor-doc' = 벤더 공개 API 문서의 엔드포인트·필드명에 정합(docUrl 로 출처 명시). 실 시스템 검증은 별도.\n * 'standard' = 개방 표준(EPCIS 2.0 / ISA-95)에 정합 — 벤더 독립.\n * 'facsimile' = 시스템 어휘를 흉내낸 근사치(공개 문서 미정합). 실 연동 시 매핑 재정합 필요.\n * 미지정은 facsimile 로 간주(가장 보수적).\n */\n grounding?: 'vendor-doc' | 'standard' | 'facsimile'\n docUrl?: string // grounding='vendor-doc' 의 근거 문서 URL(출처 추적)\n}\n\n/**\n * 원본이 **선언하는 능력** — 「이 원본으로 무엇을 할 수 있나」 (ADR-0029 §8, 2026-08-19).\n *\n * ── 왜 능력으로 선언하나 ────────────────────────────────────────────────────\n * 실 WMS 는 스스로 사실을 낸다. 시뮬레이터는 **구동해 줘야** 움직인다 — 자극·속도·정지·초기화. 그런데\n * 계약에 그 자리가 없어서 통제가 `TwinEngine` 안에 뚫려 있었다(`controlTwinScenario` 가 런타임의 시나리오\n * 엔진을 직접 잡았다). 그러면 ADR-0029 의 판정 문장(*\"실물이 같은 계약으로 할 수 있는가\"*)에 걸린다:\n * 실 WMS 는 일시정지될 수 없으니 그 통제면은 영원히 「어댑터 규약의 결손」으로 남고, 규칙이 그 면을\n * **숨게** 만든다.\n *\n * 그래서 뒤집는다: 통제는 **선언된 능력**이다. 선언하지 않은 원본에는 그 문이 **아예 열리지 않는다** —\n * 그것이 결손이 아니라 **사실**이다(실 WMS 는 정지되지 않는다). 이 레포가 이미 그 규율로 굴러간다\n * (`capability-contract`: concrete 강결합 대신 능력 선언).\n *\n * · `live` — 돌아가는 피드를 낸다(`openLiveFeed`).\n * · `control` — 구동을 받는다(`control`) — 자극·속도·정지·초기화.\n */\nexport type ReferenceCapability = 'live' | 'control'\n\n/** 원본에 보내는 구동 명령 — **시뮬레이터를 움직이는 말**이지 트윈을 고치는 말이 아니다. */\nexport interface ControlCommand {\n /**\n * · `stimulus` — 자극을 싣는다(`scenario`). 선언은 원본 설정에 살고 이 명령은 지금 태우는 것이다.\n * · `start`·`pause` — 구동을 켜고 멈춘다.\n * · `speed` — 시각의 배속(`speed`).\n * · `reset` — 씨앗부터 다시(원본의 상태를 되돌린다 — 트윈의 저널은 재기동 정책이 정한다).\n */\n action: 'stimulus' | 'start' | 'pause' | 'speed' | 'reset'\n /** 자극 선언 — `action: 'stimulus'` 에만. 커널 규약(`validateScenario`)으로 검사된 값이어야 한다. */\n scenario?: any\n /** 배속 — `action: 'speed'` 에만. */\n speed?: number\n /**\n * 어느 트윈을 위한 구동인가 — 원본이 여러 트윈에 먹일 수 있으므로 대상을 함께 말한다.\n *\n * 인프로세스 시뮬레이터는 이 둘로 자기 런타임을 찾는다(테넌트까지 있어야 같은 id 의 두 트윈이 갈린다).\n * 원격 원본은 대개 무시한다(자기 세계가 하나다).\n */\n instanceId?: string\n domainId?: string\n}\n\n/** 구동 결과 — **왜 안 됐는지**를 코드로 말한다(화면이 옮긴다). */\nexport interface ControlResult {\n ok: boolean\n /** 언어중립 사유 — `not-declared`(능력 없음) · `unsupported-action` · `invalid`(선언 거절) · `no-target`. */\n code?: string\n detail?: string\n}\n\n/**\n * 한 흐름의 읽기 커서 — **어디까지 읽었나.**\n *\n * `since` 와 `seen` 이 **함께** 있어야 한다. 이어 읽기는 경계 시각을 겹쳐 읽고(`gte`), 그 겹침을\n * `seen` 이 걸러 낸다. `gt` 로 좁히면 같은 밀리초의 다른 행이 사라진다 — 조용한 누락이다.\n *\n * `seen` 이 예상보다 커지면(한 시각에 몰린 행 수가 늘면) 그것은 원본 쪽 신호다. 어댑터가 그 크기를\n * 말해야 한다 — 조용히 커지게 두면 이어 읽기가 막히는 순간까지 아무도 모른다.\n */\nexport interface LiveFeedCursor {\n /** 그 흐름에서 마지막으로 본 시각(원본의 시각, ISO). */\n since?: string\n /** 그 시각에 이미 소비한 행 id — 겹쳐 읽은 것을 걸러 낸다. 저장되는 모양이므로 배열이다. */\n seen: string[]\n}\n\n/**\n * 재기동을 넘어 이어지는 읽기 상태 — 참조 계층이 소유하고 어댑터에 건넨다(§`openLiveFeed`).\n *\n * 흐름 열쇠는 **어댑터가 정한다**(`Record`). 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다.\n */\nexport interface LiveFeedContinuity {\n /** 지난번에 어디까지 읽었나 — 없으면 첫 붙음이다(그때만 되돌아볼 날수로 창을 만든다). */\n cursor?: { streams?: Record<string, LiveFeedCursor>; firstAttachedAt?: string }\n /**\n * 커서가 움직였다고 알린다 — 이 층이 저장한다.\n *\n * 어댑터가 **매 행마다 부르지 않는다**: 저장은 이 층의 몫이고, 잦으면 이 층이 눌린다. 흐름 하나가\n * 한 묶음을 소비한 뒤 한 번이면 충분하다.\n */\n onCursor?: (cursor: { streams: Record<string, LiveFeedCursor>; firstAttachedAt?: string }) => void\n /**\n * **원본에 닿지 못했다** — 어댑터가 읽기 실패를 알린다(2026-08-23).\n *\n * ── 무엇이 조용했나 ─────────────────────────────────────────────────────────\n * 실 원본이 끊겼을 때(접속 시간 초과) 트윈의 **조회 가능한 상태 어디에도** 그 사실이 없었다.\n * `onRecords` 가 불리지 않으면 유입 장부에 아무 일도 일어나지 않고, `TwinReference.lastError` 는\n * 마스터 동기·접속 시험에만 적힌다. 그래서 화면이 볼 수 있는 것은 「새 사실이 없다」뿐이었고,\n * 그것은 **「원본이 조용하다」와 구별되지 않는다.**\n *\n * 어댑터는 로그로 말하고 있었다(재시도 경고). 그러나 **로그는 사람이 볼 때만 값이 있다** — 화면이\n * 말하려면 상태에 있어야 한다. 이 통로가 그 자리다.\n *\n * ── 무엇을 알리고 무엇을 알리지 않나 ────────────────────────────────────────\n * **재시도를 다 쓰고 그 주기를 포기했을 때** 부른다 — 재시도마다 부르면 한 번의 흔들림이 단절로 보인다.\n * 흐름 여럿을 읽는 원본은 흐름 이름을 함께 준다(어느 표가 막혔나가 원인 찾기의 절반이다).\n *\n * **빈 읽기는 실패가 아니다** — 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이므로 `onRecords([])`\n * 로 알린다. 그 둘을 섞으면 조용한 원본이 끊긴 원본으로 보인다(고치려던 것의 반대 방향으로 틀린다).\n */\n onReadFailure?: (info: { reason: string; stream?: string }) => void\n /**\n * **읽었는데 창을 넘길 수 없다** — 어댑터가 커서 정체를 알린다 (2026-08-24).\n *\n * ── 왜 `onReadFailure` 와 갈라야 하나 ───────────────────────────────────────\n * 이 문을 만들기 전에는 두 사실이 **같은 이름으로** 나갔다.\n *\n * 원본에 닿지 못했다 접속 실패·시간 초과·형식 오류 → 기다리면 풀린다\n * 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행이 몰려 있다 → **기다려도 안 풀린다**\n *\n * 둘째는 **읽기가 성공한 실패**다. 원본은 답했고, 그 답의 모양이 커서를 이긴 것이다. 그런데 화면이\n * 「원본에 닿지 못한다」고 말하면 사람을 반대 방향으로 보낸다 — 원본을 의심하고 기다린다. 실제로\n * 필요한 조치는 **페이지를 키우거나 같은 시각 안에서 순서를 정하는 것**이고, 기다림으로는 영원히\n * 풀리지 않는다.\n *\n * 조치가 반대인 두 사실을 한 이름으로 부르면, 그 이름은 정보가 아니라 오해다.\n *\n * ── 무엇을 알리나 ───────────────────────────────────────────────────────────\n * 그 주기를 포기했을 때 부른다(`onReadFailure` 와 같은 규율). 어느 흐름인지 알면 함께 준다 —\n * 밀도가 높은 표는 원본마다 다르므로 그 이름이 조치의 절반이다.\n *\n * **닿지 못한 것과 섞어 부르지 않는다.** 하나의 주기가 두 이유로 실패할 수는 없다(먼저 닿아야 읽는다).\n */\n onCursorStall?: (info: { reason: string; stream?: string }) => void\n}\n\nexport interface ReferenceAdapter {\n /** 레지스트리 키 (예: 'virtual' | 'sap-ewm' | 'custom-rest'). */\n type: string\n /** picker·연결 폼용 메타데이터(선택). 없으면 type 만 노출. */\n meta?: AdapterMeta\n /** 연결 확인 — UI '연결 테스트'. */\n testConnection(cfg: ConnectionConfig): Promise<{ ok: boolean; error?: string }>\n /** N 발견 — 사이트/공장 열거. 단일 소스는 길이 1 반환. */\n discoverSites(cfg: ConnectionConfig): Promise<SiteDescriptor[]>\n /** 사이트별 마스터(구조) — 기존 masterToTwin 이 소비. */\n /**\n * 이 어댑터가 마스터를 읽으며 **보낼 단계를 미리 선언한다** — 그것이 진행률의 분모다.\n *\n * 표준 낱말만 쓴다(`REFERENCE_STEPS`). 선언하지 않으면 화면은 퍼센트를 보이지 않고 단계 이름만\n * 보인다 — **모르는 진행률을 지어내지 않는다.**\n *\n * 원본에 없는 자리는 선언하지 않는다. 그러면 「이 원본으로 채울 수 있는 만큼」이 곧 분모가 된다.\n */\n masterSteps?: readonly ReferenceStep[]\n\n /**\n * @param onStep 진행을 알리는 통로 — **선택이다.** 주지 않으면 예전과 같이 동작한다.\n *\n * 마스터 읽기는 원본에 여러 질의를 보내는 긴 작업이고(창고·자리·설비·공정·경로·품목·BOM), 화면은 그\n * 사이에 아무것도 알 수 없었다. 어댑터만 자기 단계를 아므로 어댑터가 말해야 한다 — 호스트가 짐작하면\n * 그것은 지어낸 진행률이다.\n *\n * `total` 은 **그 어댑터가 보낼 단계 수**다. 모르면 주지 않는다(화면이 퍼센트를 만들지 않는다).\n */\n fetchMaster(\n cfg: ConnectionConfig,\n site: SiteDescriptor,\n onStep?: (step: { key: ReferenceStep; done: number; total?: number; detail?: string }) => void\n ): Promise<ReferenceMaster>\n /**\n * (선택) 사이트별 실 이벤트 스트림 — 없으면 sim. onRecords 로 레코드 push, unsubscribe 반환.\n *\n * ── 네 번째 인자: **읽기 커서** (2026-08-23) ────────────────────────────────\n *\n * 왜 이 층이 커서를 드나: 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`) — 「원천이 **애초에\n * 다시 말해 주지 않는 축**」은 재기동 연속성으로 이어받는다. **「우리가 어디까지 읽었나」가 정확히 그\n * 성질이다.** 원본은 그것을 되풀어 주지 않는다.\n *\n * 커서가 없던 동안 어댑터는 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들었다. 그래서\n * **재기동마다 미러의 과거가 잘렸다** — 실측으로 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는\n * 것이 「원본의 사실」이 아니라 「창의 함수」였다.\n *\n * 그런데 미러의 상태는 **원천으로 서야 한다**(같은 절: 「미러의 진실은 원천이다 … 심으면 떠난 물건이\n * 되살아난다」). 그러니 저널을 접어 상태를 세우는 것으로 고칠 일이 아니다 — 그 선언의 **전제**가\n * 「원천이 다시 말해 준다」이고, 창이 좁아지면 그 전제가 깨진다. 창을 고치는 것이 그 원칙을 지키는 길이다.\n *\n * ── 계약의 모양 ─────────────────────────────────────────────────────────────\n * · **흐름 열쇠는 어댑터가 정한다.** 원본마다 흐름 수와 뜻이 다르다(이 원본은 재고·오더·로트·투입 넷).\n * 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다 — 원본의 스키마가 계약을 끌고 가는 그 모양이다.\n * · **저장되는 모양으로 넘긴다**(`seen` 은 배열). 이 층은 저장·복원만 하고 안을 해석하지 않는다.\n * `Set` 을 받으면 이 층이 직렬화를 알게 되고, 커서 모양이 바뀔 때 두 곳을 고친다.\n * · `firstAttachedAt` — **첫 창을 만든 시각.** 이후 재기동에서 바뀌지 않는다. 커널은 아는 구간의\n * 상한만 안다(`nowTime` = 마지막으로 들은 시각). 이 값이 하한이고, 둘이 「이 트윈이 아는 구간」이다.\n * 화면이 수를 보일 때 그 구간을 함께 말해야 한다 — 말하지 않으면 조용히 자르는 것이 된다.\n * · **커서가 없을 때만** 되돌아볼 날수를 쓴다. 그 순간이 `firstAttachedAt` 이다.\n *\n * 주지 않는 호출자와도 함께 선다(선택 인자) — 커서를 모르는 배포는 예전처럼 동작한다.\n */\n openLiveFeed?(\n cfg: ConnectionConfig,\n site: SiteDescriptor,\n onRecords: (records: unknown[]) => void,\n continuity?: LiveFeedContinuity\n ): () => void\n /**\n * 이 원본이 **선언하는 능력** — 없으면 「구조만 준다」는 뜻이다(ADR-0029 §8).\n *\n * 선언과 구현이 어긋나지 않게 `capabilitiesOf` 가 둘을 함께 본다: `control` 을 선언했는데 `control`\n * 구현이 없으면 그 능력은 **없는 것으로 읽는다**(선언만 있는 능력은 사용자에게 거짓말이 된다).\n */\n capabilities?: ReferenceCapability[]\n /**\n * 이 커넥터가 **무엇에 대고 확인됐나** — 능력과 다른 축이다(2026-08-23).\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * `capabilities` 는 「무엇을 할 수 있다고 선언하나」이고, 이 자리는 **「그 선언이 무엇으로 확인됐나」**다.\n * 둘이 갈리는 것이 지금 상태다: 등록된 커넥터 열넷 중 실 시스템에 붙어 본 것은 **하나**이고, 나머지는\n * 목(mock)에 대고 만들었다. 그런데 화면의 커넥터 목록에 그 구분이 없어서 **목으로 확인한 것이 확인으로\n * 보인다.**\n *\n * 그것은 이 저장소가 다른 자리에서 계속 거절하는 모양이다 — 「선언만 있는 능력은 사용자에게\n * 거짓말이 된다」(§`capabilitiesOf`). 능력에 세운 그 규율을 근거에도 세운다.\n *\n * ── 왜 계약이고 배포 노트가 아닌가 ──────────────────────────────────────────\n * 노트는 다음 사람이 보지 않는다. 계약에 있으면 새 커넥터가 그 값을 **빠뜨릴 수 없고**, 빠뜨리면\n * `groundingOf` 가 `unknown` 으로 답해 화면이 그 사실을 말한다.\n *\n * ── 자동으로 알 수 없는 값이다 ──────────────────────────────────────────────\n * 능력은 구현이 있는지 보면 알 수 있다. 「실 시스템에 붙여 봤나」는 **볼 수 없다** — 사람이 아는 사실이다.\n * 그래서 이 선언은 낡을 수 있고, 그것을 막는 것이 `verifiedAgainst` 다: **무엇에 대고 확인했는지 적지\n * 않으면 그 주장은 주장이 아니다**(`groundingOf` 가 `unknown` 으로 내린다).\n */\n grounding?: ReferenceGrounding\n /**\n * (선택) 구동 — 자극·속도·정지·초기화. **`capabilities` 에 `control` 을 선언한 원본만** 가진다.\n *\n * 실 시스템은 이것을 갖지 않는다(실 WMS 는 일시정지되지 않는다). 그 사실이 결손이 아니라 이 축의 요점이다.\n */\n control?(cfg: ConnectionConfig, site: SiteDescriptor | undefined, command: ControlCommand): Promise<ControlResult>\n}\n\n/**\n * 이 원본이 **실제로 할 수 있는 것** — 선언과 구현의 교집합.\n *\n * 선언만 있고 구현이 없으면 화면이 「할 수 있다」고 말한 뒤 아무 일도 일어나지 않는다. 반대로 구현이\n * 있는데 선언이 없으면 그 능력은 **의도적으로 닫아 둔 것**이므로 열지 않는다 — 선언이 권위다.\n */\n/**\n * 근거 수준 — **무엇에 대고 확인했나.** 넷이고 닫혀 있다.\n *\n * `synthetic` 실 시스템이 아니다 — 스스로 만들어 낸다(가상 원본·시뮬레이터). 한계가 아니라 성질이다.\n * `mock` 목·목 REST 에 대고만 확인했다. **실 시스템에 붙어 본 적이 없다.**\n * `instance` 실 시스템 **한 배포**에 붙여 확인했다. 그 배포의 성질과 제품의 성질을 아직 가르지 못했다.\n * `product` 실 시스템 **여러 배포**에 붙여 확인했다 — 그때 비로소 제품의 성질이라 말할 수 있다.\n *\n * `instance` 와 `product` 를 가르는 이유: 첫 실 연동에서 그 배포만의 성질이 넷 드러났고(관계 리솔버가\n * 다른 테넌트의 행을 답한다 · 모르는 필터를 조용히 무시한다 · 지워진 품목의 BOM 이 남아 있다 ·\n * 사실상 무한을 뜻하는 표식) **그것이 그 제품의 성질인지 그 인스턴스의 성질인지 아무도 모른다.**\n * 두 번째 배포가 붙는 날 갈린다. 그 사실을 어휘가 담는다.\n */\nexport type ReferenceGroundingLevel = 'synthetic' | 'mock' | 'instance' | 'product'\n\nexport interface ReferenceGrounding {\n level: ReferenceGroundingLevel\n /**\n * **무엇에 대고 확인했나** — 사람이 읽는 한 줄(원본의 판·인스턴스·목의 이름).\n *\n * `instance`·`product` 를 주장하면서 이것이 비어 있으면 `groundingOf` 가 `unknown` 으로 내린다 —\n * 가리키는 대상이 없는 주장은 주장이 아니고, 그런 주장은 낡아도 아무도 알 수 없다.\n */\n verifiedAgainst?: string\n}\n\n/**\n * 이 커넥터의 근거 — **주장을 그대로 믿지 않는다**(`capabilitiesOf` 와 같은 규율).\n *\n * 능력은 구현이 있는지 보아 선언을 검증할 수 있다. 근거는 **볼 수 없다** — 사람이 아는 사실이다.\n * 그래서 검증할 수 있는 것 하나만 검증한다: **실 시스템을 주장하면 무엇에 대고인지 말해야 한다.**\n *\n * 선언이 없으면 `unknown` 이다 — 「목이다」가 아니라 **「모른다」**다. 둘을 같게 두면 아직 선언하지 않은\n * 새 커넥터가 목으로 낙인찍히고, 그것도 없는 사실이다.\n */\nexport function groundingOf(adapter: ReferenceAdapter | undefined): {\n level: ReferenceGroundingLevel | 'unknown'\n verifiedAgainst?: string\n /** 주장이 내려갔으면 그 이유 — 화면이 그대로 옮긴다. */\n downgraded?: string\n} {\n const g = adapter?.grounding\n if (!g?.level) return { level: 'unknown' }\n const against = g.verifiedAgainst?.trim()\n if ((g.level === 'instance' || g.level === 'product') && !against) {\n return {\n level: 'unknown',\n downgraded: `claims \"${g.level}\" but does not say what it was verified against`\n }\n }\n return { level: g.level, ...(against ? { verifiedAgainst: against } : {}) }\n}\n\nexport function capabilitiesOf(adapter: ReferenceAdapter | undefined): ReferenceCapability[] {\n if (!adapter) return []\n const declared = new Set(adapter.capabilities ?? [])\n const out: ReferenceCapability[] = []\n if (declared.has('live') && typeof adapter.openLiveFeed === 'function') out.push('live')\n if (declared.has('control') && typeof adapter.control === 'function') out.push('control')\n return out\n}\n\n/* 어댑터 \"타입\" 레지스트리(전역·인메모리) — 레퍼런스 데이터는 DB(TwinReference), 어댑터 동작은 여기. */\nconst adapters = new Map<string, ReferenceAdapter>()\n\nexport function registerAdapterType(adapter: ReferenceAdapter): void {\n adapters.set(adapter.type, adapter)\n}\nexport function getAdapter(type: string): ReferenceAdapter | undefined {\n return adapters.get(type)\n}\nexport function listAdapterTypes(): string[] {\n return [...adapters.keys()]\n}\n/** 커넥터 메타데이터 목록(picker·연결 폼용). */\n/**\n * 등록된 어댑터 목록 — **선언한 능력도 함께 낸다.**\n *\n * 예전에는 `{ type, meta }` 만 냈습니다. 그런데 소비처(어댑터 목록 질의)가 `capabilities` 를 함께\n * 내보내고 있어 타입검사가 막혔고, 그 상태로는 개발 서버 빌드가 실패합니다. 능력은 이 목록을 읽는\n * 화면이 「이 원본에 무엇을 요구할 수 있나」를 판단하는 근거이므로, 빼지 않고 여기서 함께 냅니다.\n */\nexport function listAdapters(): { type: string; meta?: AdapterMeta; capabilities?: ReferenceCapability[] }[] {\n return [...adapters.values()].map(a => ({ type: a.type, meta: a.meta, capabilities: a.capabilities }))\n}\n"]}
1
+ {"version":3,"file":"reference-adapter.js","sourceRoot":"","sources":["../../../server/service/reference/reference-adapter.ts"],"names":[],"mappings":";;AA8VA,kCAgBC;AAED,wCAOC;AAKD,kDAEC;AACD,gCAEC;AACD,4CAEC;AASD,oCAEC;AA1DD;;;;;;;;GAQG;AACH,SAAgB,WAAW,CAAC,OAAqC;IAM/D,MAAM,CAAC,GAAG,OAAO,EAAE,SAAS,CAAA;IAC5B,IAAI,CAAC,CAAC,EAAE,KAAK;QAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAA;IAC1C,MAAM,OAAO,GAAG,CAAC,CAAC,eAAe,EAAE,IAAI,EAAE,CAAA;IACzC,IAAI,CAAC,CAAC,CAAC,KAAK,KAAK,UAAU,IAAI,CAAC,CAAC,KAAK,KAAK,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;QAClE,OAAO;YACL,KAAK,EAAE,SAAS;YAChB,UAAU,EAAE,WAAW,CAAC,CAAC,KAAK,iDAAiD;SAChF,CAAA;IACH,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAA;AAC7E,CAAC;AAED,SAAgB,cAAc,CAAC,OAAqC;IAClE,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAA;IACvB,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,CAAA;IACpD,MAAM,GAAG,GAA0B,EAAE,CAAA;IACrC,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,OAAO,OAAO,CAAC,YAAY,KAAK,UAAU;QAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxF,IAAI,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,OAAO,OAAO,CAAC,OAAO,KAAK,UAAU;QAAE,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;IACzF,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,wEAAwE;AACxE,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA4B,CAAA;AAEpD,SAAgB,mBAAmB,CAAC,OAAyB;IAC3D,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;AACrC,CAAC;AACD,SAAgB,UAAU,CAAC,IAAY;IACrC,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;AAC3B,CAAC;AACD,SAAgB,gBAAgB;IAC9B,OAAO,CAAC,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;AAC7B,CAAC;AACD,kCAAkC;AAClC;;;;;;GAMG;AACH,SAAgB,YAAY;IAC1B,OAAO,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CAAA;AACxG,CAAC","sourcesContent":["import type { ReferenceStep } from './reference-progress.js'\nimport type { ReferenceMaster } from './reference-master.js'\n\n/*\n * 레퍼런스 어댑터 계약 (reference-management P1) — 정적 마스터 등록(`registerReference`)을\n * \"어댑터 타입\" 등록으로 일반화한다. 레퍼런스 = 데이터(TwinReference 행), 어댑터 = 동작.\n * inbound 전용(구조·상태 유입). 아웃바운드 액추에이션은 command-routing(별도 ActuationAdapter) 소유.\n * seam: openLiveFeed 레코드 → 커널 face2-adapter.ingest → CanonicalEnvelope 까지가 여기,\n * 이후 projector 구동은 face2-inbound-live 소유.\n * 설계 SoT: operato-twin/design/plans/reference-management.md §2.2·§4.2.\n */\n\n/** 연결 설정(어댑터별 스키마) — 엔드포인트·자격·파라미터. */\nexport type ConnectionConfig = Record<string, any>\n\n/** discoverSites 가 반환하는 사이트/공장 서술자 — N 발견(§2.2). 1:1 은 길이 1 배열. */\nexport interface SiteDescriptor {\n /** 안정적 사이트 정체성 키(소스의 plant code 등) — 공간 정합 1차 키. */\n siteId: string\n name?: string\n /** 사이트별 시스템(소스가 시스템을 넘나들 때). 없으면 레퍼런스 system 사용. */\n system?: string\n geo?: { lat: number; lon: number }\n hint?: any\n}\n\n/** 연결 폼 필드 서술 — UI 가 이 스키마로 connectionConfig 입력 폼을 렌더한다(어댑터가 스키마 제공). */\nexport interface AdapterConfigField {\n key: string\n label: string\n placeholder?: string\n required?: boolean\n secret?: boolean // 비밀번호/토큰 — 마스킹 입력\n multiline?: boolean // JSON/매핑 등 여러 줄 입력(textarea)\n help?: string // 필드 아래 도움말\n default?: string // 폼 프리필 기본값(예시 매핑/URL 등)\n}\n/** 커넥터 메타데이터 — picker·연결 폼용 표시 정보. */\nexport interface AdapterMeta {\n label?: string\n domain?: string // 'wms' | 'yms' | 'mes' (분류)\n available?: boolean // false = 준비 중(picker 비활성)\n configSchema?: AdapterConfigField[]\n ootbNote?: string // OOTB 매핑 안내 문구\n /*\n * 근거 수준(정직성 1급 표기 — \"실제 API 명세에 근거하는가\"를 UI·피치가 오해 없이 보이게).\n * 'vendor-doc' = 벤더 공개 API 문서의 엔드포인트·필드명에 정합(docUrl 로 출처 명시). 실 시스템 검증은 별도.\n * 'standard' = 개방 표준(EPCIS 2.0 / ISA-95)에 정합 — 벤더 독립.\n * 'facsimile' = 시스템 어휘를 흉내낸 근사치(공개 문서 미정합). 실 연동 시 매핑 재정합 필요.\n * 미지정은 facsimile 로 간주(가장 보수적).\n */\n grounding?: 'vendor-doc' | 'standard' | 'facsimile'\n docUrl?: string // grounding='vendor-doc' 의 근거 문서 URL(출처 추적)\n}\n\n/**\n * 원본이 **선언하는 능력** — 「이 원본으로 무엇을 할 수 있나」 (ADR-0029 §8, 2026-08-19).\n *\n * ── 왜 능력으로 선언하나 ────────────────────────────────────────────────────\n * 실 WMS 는 스스로 사실을 낸다. 시뮬레이터는 **구동해 줘야** 움직인다 — 자극·속도·정지·초기화. 그런데\n * 계약에 그 자리가 없어서 통제가 `TwinEngine` 안에 뚫려 있었다(`controlTwinScenario` 가 런타임의 시나리오\n * 엔진을 직접 잡았다). 그러면 ADR-0029 의 판정 문장(*\"실물이 같은 계약으로 할 수 있는가\"*)에 걸린다:\n * 실 WMS 는 일시정지될 수 없으니 그 통제면은 영원히 「어댑터 규약의 결손」으로 남고, 규칙이 그 면을\n * **숨게** 만든다.\n *\n * 그래서 뒤집는다: 통제는 **선언된 능력**이다. 선언하지 않은 원본에는 그 문이 **아예 열리지 않는다** —\n * 그것이 결손이 아니라 **사실**이다(실 WMS 는 정지되지 않는다). 이 레포가 이미 그 규율로 굴러간다\n * (`capability-contract`: concrete 강결합 대신 능력 선언).\n *\n * · `live` — 돌아가는 피드를 낸다(`openLiveFeed`).\n * · `control` — 구동을 받는다(`control`) — 자극·속도·정지·초기화.\n */\nexport type ReferenceCapability = 'live' | 'control'\n\n/** 원본에 보내는 구동 명령 — **시뮬레이터를 움직이는 말**이지 트윈을 고치는 말이 아니다. */\nexport interface ControlCommand {\n /**\n * · `stimulus` — 자극을 싣는다(`scenario`). 선언은 원본 설정에 살고 이 명령은 지금 태우는 것이다.\n * · `start`·`pause` — 구동을 켜고 멈춘다.\n * · `speed` — 시각의 배속(`speed`).\n * · `reset` — 씨앗부터 다시(원본의 상태를 되돌린다 — 트윈의 저널은 재기동 정책이 정한다).\n */\n action: 'stimulus' | 'start' | 'pause' | 'speed' | 'reset'\n /** 자극 선언 — `action: 'stimulus'` 에만. 커널 규약(`validateScenario`)으로 검사된 값이어야 한다. */\n scenario?: any\n /** 배속 — `action: 'speed'` 에만. */\n speed?: number\n /**\n * 어느 트윈을 위한 구동인가 — 원본이 여러 트윈에 먹일 수 있으므로 대상을 함께 말한다.\n *\n * 인프로세스 시뮬레이터는 이 둘로 자기 런타임을 찾는다(테넌트까지 있어야 같은 id 의 두 트윈이 갈린다).\n * 원격 원본은 대개 무시한다(자기 세계가 하나다).\n */\n instanceId?: string\n domainId?: string\n}\n\n/** 구동 결과 — **왜 안 됐는지**를 코드로 말한다(화면이 옮긴다). */\nexport interface ControlResult {\n ok: boolean\n /** 언어중립 사유 — `not-declared`(능력 없음) · `unsupported-action` · `invalid`(선언 거절) · `no-target`. */\n code?: string\n detail?: string\n}\n\n/**\n * 한 흐름의 읽기 커서 — **어디까지 읽었나.**\n *\n * `since` 와 `seen` 이 **함께** 있어야 한다. 이어 읽기는 경계 시각을 겹쳐 읽고(`gte`), 그 겹침을\n * `seen` 이 걸러 낸다. `gt` 로 좁히면 같은 밀리초의 다른 행이 사라진다 — 조용한 누락이다.\n *\n * `seen` 이 예상보다 커지면(한 시각에 몰린 행 수가 늘면) 그것은 원본 쪽 신호다. 어댑터가 그 크기를\n * 말해야 한다 — 조용히 커지게 두면 이어 읽기가 막히는 순간까지 아무도 모른다.\n */\nexport interface LiveFeedCursor {\n /** 그 흐름에서 마지막으로 본 시각(원본의 시각, ISO). */\n since?: string\n /** 그 시각에 이미 소비한 행 id — 겹쳐 읽은 것을 걸러 낸다. 저장되는 모양이므로 배열이다. */\n seen: string[]\n}\n\n/**\n * 재기동을 넘어 이어지는 읽기 상태 — 참조 계층이 소유하고 어댑터에 건넨다(§`openLiveFeed`).\n *\n * 흐름 열쇠는 **어댑터가 정한다**(`Record`). 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다.\n */\nexport interface LiveFeedContinuity {\n /** 지난번에 어디까지 읽었나 — 없으면 첫 붙음이다(그때만 되돌아볼 날수로 창을 만든다). */\n cursor?: { streams?: Record<string, LiveFeedCursor>; firstAttachedAt?: string }\n /**\n * 커서가 움직였다고 알린다 — 이 층이 저장한다.\n *\n * 어댑터가 **매 행마다 부르지 않는다**: 저장은 이 층의 몫이고, 잦으면 이 층이 눌린다. 흐름 하나가\n * 한 묶음을 소비한 뒤 한 번이면 충분하다.\n */\n onCursor?: (cursor: { streams: Record<string, LiveFeedCursor>; firstAttachedAt?: string }) => void\n /**\n * **원본에 닿지 못했다** — 어댑터가 읽기 실패를 알린다(2026-08-23).\n *\n * ── 무엇이 조용했나 ─────────────────────────────────────────────────────────\n * 실 원본이 끊겼을 때(접속 시간 초과) 트윈의 **조회 가능한 상태 어디에도** 그 사실이 없었다.\n * `onRecords` 가 불리지 않으면 유입 장부에 아무 일도 일어나지 않고, `TwinReference.lastError` 는\n * 마스터 동기·접속 시험에만 적힌다. 그래서 화면이 볼 수 있는 것은 「새 사실이 없다」뿐이었고,\n * 그것은 **「원본이 조용하다」와 구별되지 않는다.**\n *\n * 어댑터는 로그로 말하고 있었다(재시도 경고). 그러나 **로그는 사람이 볼 때만 값이 있다** — 화면이\n * 말하려면 상태에 있어야 한다. 이 통로가 그 자리다.\n *\n * ── 무엇을 알리고 무엇을 알리지 않나 ────────────────────────────────────────\n * **재시도를 다 쓰고 그 주기를 포기했을 때** 부른다 — 재시도마다 부르면 한 번의 흔들림이 단절로 보인다.\n * 흐름 여럿을 읽는 원본은 흐름 이름을 함께 준다(어느 표가 막혔나가 원인 찾기의 절반이다).\n *\n * **빈 읽기는 실패가 아니다** — 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이므로 `onRecords([])`\n * 로 알린다. 그 둘을 섞으면 조용한 원본이 끊긴 원본으로 보인다(고치려던 것의 반대 방향으로 틀린다).\n */\n onReadFailure?: (info: { reason: string; stream?: string }) => void\n /**\n * **읽었는데 창을 넘길 수 없다** — 어댑터가 커서 정체를 알린다 (2026-08-24).\n *\n * ── 왜 `onReadFailure` 와 갈라야 하나 ───────────────────────────────────────\n * 이 문을 만들기 전에는 두 사실이 **같은 이름으로** 나갔다.\n *\n * 원본에 닿지 못했다 접속 실패·시간 초과·형식 오류 → 기다리면 풀린다\n * 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행이 몰려 있다 → **기다려도 안 풀린다**\n *\n * 둘째는 **읽기가 성공한 실패**다. 원본은 답했고, 그 답의 모양이 커서를 이긴 것이다. 그런데 화면이\n * 「원본에 닿지 못한다」고 말하면 사람을 반대 방향으로 보낸다 — 원본을 의심하고 기다린다. 실제로\n * 필요한 조치는 **페이지를 키우거나 같은 시각 안에서 순서를 정하는 것**이고, 기다림으로는 영원히\n * 풀리지 않는다.\n *\n * 조치가 반대인 두 사실을 한 이름으로 부르면, 그 이름은 정보가 아니라 오해다.\n *\n * ── 무엇을 알리나 ───────────────────────────────────────────────────────────\n * 그 주기를 포기했을 때 부른다(`onReadFailure` 와 같은 규율). 어느 흐름인지 알면 함께 준다 —\n * 밀도가 높은 표는 원본마다 다르므로 그 이름이 조치의 절반이다.\n *\n * **닿지 못한 것과 섞어 부르지 않는다.** 하나의 주기가 두 이유로 실패할 수는 없다(먼저 닿아야 읽는다).\n */\n onCursorStall?: (info: { reason: string; stream?: string }) => void\n /**\n * **원본에 있는데 세우지 않은 것을 알린다** — 이유와 수.\n *\n * ── 왜 이 통로가 필요한가 (2026-08-24) ─────────────────────────────────────\n * 어댑터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**(입자가 안 맞는다 · 받으면 상태가\n * 거짓이 된다 · 커널 어휘로 옮길 수 없다). 그때 어댑터는 경고를 냈는데, 그 경고가 **프로비저닝\n * 화면에서 한 번 스쳐 지나갈 뿐**이었다 — 그 뒤로는 어디에서도 볼 수 없었다.\n *\n * 그래서 사용자가 「원본보다 이것이 적다」를 물으면 답이 어디에도 없었다. 그 사실은 한 번의 사건이\n * 아니라 **상태의 성질**이다: 세우지 않은 것은 **지금도** 트윈에 없다.\n *\n * **주기마다 불러도 된다** — 장부가 이유로 묶어 세고 마지막 수로 덮는다(누적하지 않는다). 그래서\n * 「지금 세우지 않은 것이 8건」이 8로 남고 800으로 부풀지 않는다.\n *\n * **풀리면 부르지 않는 것으로 끝나지 않는다** — 이유가 사라졌으면 `count: 0` 으로 부르는 것이 아니라\n * 그 이유를 더 이상 보내지 않으면 된다… 가 **아니다.** 장부는 마지막 값을 들고 있으므로, 풀린 것을\n * 알리려면 그 이유로 `count: 0` 을 한 번 보내라(그때 줄이 사라진다). 조용히 그치면 낡은 수가 남는다.\n *\n * 이유는 **자유 문자열**이다 — 무엇을 왜 안 받는지는 원본마다 다르고, 계약이 목록을 닫으면 새 원본을\n * 붙일 때마다 계약을 고치게 된다(흐름 열쇠와 같은 규율). 사람이 읽을 문장으로 적어라.\n */\n onWithheld?: (info: { reason: string; count: number }) => void\n}\n\nexport interface ReferenceAdapter {\n /** 레지스트리 키 (예: 'virtual' | 'sap-ewm' | 'custom-rest'). */\n type: string\n /** picker·연결 폼용 메타데이터(선택). 없으면 type 만 노출. */\n meta?: AdapterMeta\n /** 연결 확인 — UI '연결 테스트'. */\n testConnection(cfg: ConnectionConfig): Promise<{ ok: boolean; error?: string }>\n /** N 발견 — 사이트/공장 열거. 단일 소스는 길이 1 반환. */\n discoverSites(cfg: ConnectionConfig): Promise<SiteDescriptor[]>\n /** 사이트별 마스터(구조) — 기존 masterToTwin 이 소비. */\n /**\n * 이 어댑터가 마스터를 읽으며 **보낼 단계를 미리 선언한다** — 그것이 진행률의 분모다.\n *\n * 표준 낱말만 쓴다(`REFERENCE_STEPS`). 선언하지 않으면 화면은 퍼센트를 보이지 않고 단계 이름만\n * 보인다 — **모르는 진행률을 지어내지 않는다.**\n *\n * 원본에 없는 자리는 선언하지 않는다. 그러면 「이 원본으로 채울 수 있는 만큼」이 곧 분모가 된다.\n */\n masterSteps?: readonly ReferenceStep[]\n\n /**\n * @param onStep 진행을 알리는 통로 — **선택이다.** 주지 않으면 예전과 같이 동작한다.\n *\n * 마스터 읽기는 원본에 여러 질의를 보내는 긴 작업이고(창고·자리·설비·공정·경로·품목·BOM), 화면은 그\n * 사이에 아무것도 알 수 없었다. 어댑터만 자기 단계를 아므로 어댑터가 말해야 한다 — 호스트가 짐작하면\n * 그것은 지어낸 진행률이다.\n *\n * `total` 은 **그 어댑터가 보낼 단계 수**다. 모르면 주지 않는다(화면이 퍼센트를 만들지 않는다).\n */\n fetchMaster(\n cfg: ConnectionConfig,\n site: SiteDescriptor,\n onStep?: (step: { key: ReferenceStep; done: number; total?: number; detail?: string }) => void\n ): Promise<ReferenceMaster>\n /**\n * (선택) 사이트별 실 이벤트 스트림 — 없으면 sim. onRecords 로 레코드 push, unsubscribe 반환.\n *\n * ── 네 번째 인자: **읽기 커서** (2026-08-23) ────────────────────────────────\n *\n * 왜 이 층이 커서를 드나: 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`) — 「원천이 **애초에\n * 다시 말해 주지 않는 축**」은 재기동 연속성으로 이어받는다. **「우리가 어디까지 읽었나」가 정확히 그\n * 성질이다.** 원본은 그것을 되풀어 주지 않는다.\n *\n * 커서가 없던 동안 어댑터는 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들었다. 그래서\n * **재기동마다 미러의 과거가 잘렸다** — 실측으로 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는\n * 것이 「원본의 사실」이 아니라 「창의 함수」였다.\n *\n * 그런데 미러의 상태는 **원천으로 서야 한다**(같은 절: 「미러의 진실은 원천이다 … 심으면 떠난 물건이\n * 되살아난다」). 그러니 저널을 접어 상태를 세우는 것으로 고칠 일이 아니다 — 그 선언의 **전제**가\n * 「원천이 다시 말해 준다」이고, 창이 좁아지면 그 전제가 깨진다. 창을 고치는 것이 그 원칙을 지키는 길이다.\n *\n * ── 계약의 모양 ─────────────────────────────────────────────────────────────\n * · **흐름 열쇠는 어댑터가 정한다.** 원본마다 흐름 수와 뜻이 다르다(이 원본은 재고·오더·로트·투입 넷).\n * 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다 — 원본의 스키마가 계약을 끌고 가는 그 모양이다.\n * · **저장되는 모양으로 넘긴다**(`seen` 은 배열). 이 층은 저장·복원만 하고 안을 해석하지 않는다.\n * `Set` 을 받으면 이 층이 직렬화를 알게 되고, 커서 모양이 바뀔 때 두 곳을 고친다.\n * · `firstAttachedAt` — **첫 창을 만든 시각.** 이후 재기동에서 바뀌지 않는다. 커널은 아는 구간의\n * 상한만 안다(`nowTime` = 마지막으로 들은 시각). 이 값이 하한이고, 둘이 「이 트윈이 아는 구간」이다.\n * 화면이 수를 보일 때 그 구간을 함께 말해야 한다 — 말하지 않으면 조용히 자르는 것이 된다.\n * · **커서가 없을 때만** 되돌아볼 날수를 쓴다. 그 순간이 `firstAttachedAt` 이다.\n *\n * 주지 않는 호출자와도 함께 선다(선택 인자) — 커서를 모르는 배포는 예전처럼 동작한다.\n */\n openLiveFeed?(\n cfg: ConnectionConfig,\n site: SiteDescriptor,\n onRecords: (records: unknown[]) => void,\n continuity?: LiveFeedContinuity\n ): () => void\n /**\n * 이 원본이 **선언하는 능력** — 없으면 「구조만 준다」는 뜻이다(ADR-0029 §8).\n *\n * 선언과 구현이 어긋나지 않게 `capabilitiesOf` 가 둘을 함께 본다: `control` 을 선언했는데 `control`\n * 구현이 없으면 그 능력은 **없는 것으로 읽는다**(선언만 있는 능력은 사용자에게 거짓말이 된다).\n */\n capabilities?: ReferenceCapability[]\n /**\n * 이 커넥터가 **무엇에 대고 확인됐나** — 능력과 다른 축이다(2026-08-23).\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * `capabilities` 는 「무엇을 할 수 있다고 선언하나」이고, 이 자리는 **「그 선언이 무엇으로 확인됐나」**다.\n * 둘이 갈리는 것이 지금 상태다: 등록된 커넥터 열넷 중 실 시스템에 붙어 본 것은 **하나**이고, 나머지는\n * 목(mock)에 대고 만들었다. 그런데 화면의 커넥터 목록에 그 구분이 없어서 **목으로 확인한 것이 확인으로\n * 보인다.**\n *\n * 그것은 이 저장소가 다른 자리에서 계속 거절하는 모양이다 — 「선언만 있는 능력은 사용자에게\n * 거짓말이 된다」(§`capabilitiesOf`). 능력에 세운 그 규율을 근거에도 세운다.\n *\n * ── 왜 계약이고 배포 노트가 아닌가 ──────────────────────────────────────────\n * 노트는 다음 사람이 보지 않는다. 계약에 있으면 새 커넥터가 그 값을 **빠뜨릴 수 없고**, 빠뜨리면\n * `groundingOf` 가 `unknown` 으로 답해 화면이 그 사실을 말한다.\n *\n * ── 자동으로 알 수 없는 값이다 ──────────────────────────────────────────────\n * 능력은 구현이 있는지 보면 알 수 있다. 「실 시스템에 붙여 봤나」는 **볼 수 없다** — 사람이 아는 사실이다.\n * 그래서 이 선언은 낡을 수 있고, 그것을 막는 것이 `verifiedAgainst` 다: **무엇에 대고 확인했는지 적지\n * 않으면 그 주장은 주장이 아니다**(`groundingOf` 가 `unknown` 으로 내린다).\n */\n grounding?: ReferenceGrounding\n /**\n * (선택) 구동 — 자극·속도·정지·초기화. **`capabilities` 에 `control` 을 선언한 원본만** 가진다.\n *\n * 실 시스템은 이것을 갖지 않는다(실 WMS 는 일시정지되지 않는다). 그 사실이 결손이 아니라 이 축의 요점이다.\n */\n control?(cfg: ConnectionConfig, site: SiteDescriptor | undefined, command: ControlCommand): Promise<ControlResult>\n}\n\n/**\n * 이 원본이 **실제로 할 수 있는 것** — 선언과 구현의 교집합.\n *\n * 선언만 있고 구현이 없으면 화면이 「할 수 있다」고 말한 뒤 아무 일도 일어나지 않는다. 반대로 구현이\n * 있는데 선언이 없으면 그 능력은 **의도적으로 닫아 둔 것**이므로 열지 않는다 — 선언이 권위다.\n */\n/**\n * 근거 수준 — **무엇에 대고 확인했나.** 넷이고 닫혀 있다.\n *\n * `synthetic` 실 시스템이 아니다 — 스스로 만들어 낸다(가상 원본·시뮬레이터). 한계가 아니라 성질이다.\n * `mock` 목·목 REST 에 대고만 확인했다. **실 시스템에 붙어 본 적이 없다.**\n * `instance` 실 시스템 **한 배포**에 붙여 확인했다. 그 배포의 성질과 제품의 성질을 아직 가르지 못했다.\n * `product` 실 시스템 **여러 배포**에 붙여 확인했다 — 그때 비로소 제품의 성질이라 말할 수 있다.\n *\n * `instance` 와 `product` 를 가르는 이유: 첫 실 연동에서 그 배포만의 성질이 넷 드러났고(관계 리솔버가\n * 다른 테넌트의 행을 답한다 · 모르는 필터를 조용히 무시한다 · 지워진 품목의 BOM 이 남아 있다 ·\n * 사실상 무한을 뜻하는 표식) **그것이 그 제품의 성질인지 그 인스턴스의 성질인지 아무도 모른다.**\n * 두 번째 배포가 붙는 날 갈린다. 그 사실을 어휘가 담는다.\n */\nexport type ReferenceGroundingLevel = 'synthetic' | 'mock' | 'instance' | 'product'\n\nexport interface ReferenceGrounding {\n level: ReferenceGroundingLevel\n /**\n * **무엇에 대고 확인했나** — 사람이 읽는 한 줄(원본의 판·인스턴스·목의 이름).\n *\n * `instance`·`product` 를 주장하면서 이것이 비어 있으면 `groundingOf` 가 `unknown` 으로 내린다 —\n * 가리키는 대상이 없는 주장은 주장이 아니고, 그런 주장은 낡아도 아무도 알 수 없다.\n */\n verifiedAgainst?: string\n}\n\n/**\n * 이 커넥터의 근거 — **주장을 그대로 믿지 않는다**(`capabilitiesOf` 와 같은 규율).\n *\n * 능력은 구현이 있는지 보아 선언을 검증할 수 있다. 근거는 **볼 수 없다** — 사람이 아는 사실이다.\n * 그래서 검증할 수 있는 것 하나만 검증한다: **실 시스템을 주장하면 무엇에 대고인지 말해야 한다.**\n *\n * 선언이 없으면 `unknown` 이다 — 「목이다」가 아니라 **「모른다」**다. 둘을 같게 두면 아직 선언하지 않은\n * 새 커넥터가 목으로 낙인찍히고, 그것도 없는 사실이다.\n */\nexport function groundingOf(adapter: ReferenceAdapter | undefined): {\n level: ReferenceGroundingLevel | 'unknown'\n verifiedAgainst?: string\n /** 주장이 내려갔으면 그 이유 — 화면이 그대로 옮긴다. */\n downgraded?: string\n} {\n const g = adapter?.grounding\n if (!g?.level) return { level: 'unknown' }\n const against = g.verifiedAgainst?.trim()\n if ((g.level === 'instance' || g.level === 'product') && !against) {\n return {\n level: 'unknown',\n downgraded: `claims \"${g.level}\" but does not say what it was verified against`\n }\n }\n return { level: g.level, ...(against ? { verifiedAgainst: against } : {}) }\n}\n\nexport function capabilitiesOf(adapter: ReferenceAdapter | undefined): ReferenceCapability[] {\n if (!adapter) return []\n const declared = new Set(adapter.capabilities ?? [])\n const out: ReferenceCapability[] = []\n if (declared.has('live') && typeof adapter.openLiveFeed === 'function') out.push('live')\n if (declared.has('control') && typeof adapter.control === 'function') out.push('control')\n return out\n}\n\n/* 어댑터 \"타입\" 레지스트리(전역·인메모리) — 레퍼런스 데이터는 DB(TwinReference), 어댑터 동작은 여기. */\nconst adapters = new Map<string, ReferenceAdapter>()\n\nexport function registerAdapterType(adapter: ReferenceAdapter): void {\n adapters.set(adapter.type, adapter)\n}\nexport function getAdapter(type: string): ReferenceAdapter | undefined {\n return adapters.get(type)\n}\nexport function listAdapterTypes(): string[] {\n return [...adapters.keys()]\n}\n/** 커넥터 메타데이터 목록(picker·연결 폼용). */\n/**\n * 등록된 어댑터 목록 — **선언한 능력도 함께 낸다.**\n *\n * 예전에는 `{ type, meta }` 만 냈습니다. 그런데 소비처(어댑터 목록 질의)가 `capabilities` 를 함께\n * 내보내고 있어 타입검사가 막혔고, 그 상태로는 개발 서버 빌드가 실패합니다. 능력은 이 목록을 읽는\n * 화면이 「이 원본에 무엇을 요구할 수 있나」를 판단하는 근거이므로, 빼지 않고 여기서 함께 냅니다.\n */\nexport function listAdapters(): { type: string; meta?: AdapterMeta; capabilities?: ReferenceCapability[] }[] {\n return [...adapters.values()].map(a => ({ type: a.type, meta: a.meta, capabilities: a.capabilities }))\n}\n"]}
@@ -163,6 +163,18 @@ instanceId, source) {
163
163
  * `status` 를 건드리지 않는 규율은 위와 같다(재부착의 자격이다). `lastError` 에는 적는다 — 목록에서
164
164
  * 이유를 볼 수 있어야 하고, 「닿지 못한다」와 다른 문장이어야 한다.
165
165
  */
166
+ /*
167
+ * **세우지 않은 것을 장부에 적는다** — 원본에 있는데 트윈에 세우지 않은 것(이유와 수).
168
+ *
169
+ * 로그로도 남기지만 **로그는 사람이 볼 때만 값이 있다.** 이 사실은 상태의 성질이라(세우지 않은 것은
170
+ * 지금도 없다) 조회되는 자리에 있어야 한다 — 그러지 않으면 프로비저닝 화면에서 한 번 스쳐 지나간다.
171
+ *
172
+ * `status`·`lastError` 를 건드리지 않는다: 이것은 **실패가 아니다.** 어댑터가 옳은 판단으로 안 받은
173
+ * 것이고, 오류로 적으면 화면이 원본을 의심하게 만든다.
174
+ */
175
+ onWithheld: ({ reason, count }) => {
176
+ index_js_1.TwinEngine.recordIngestWithheld(domainId, instanceId, reason, count);
177
+ },
166
178
  onCursorStall: ({ reason, stream }) => {
167
179
  index_js_1.TwinEngine.recordIngestCursorStall(domainId, instanceId, reason, Date.now(), stream);
168
180
  (0, log_js_1.twinWarn)(`[twin-live] "${source}": read the source but the cursor cannot advance` +
@@ -1 +1 @@
1
- {"version":3,"file":"reference-live.js","sourceRoot":"","sources":["../../../server/service/reference/reference-live.ts"],"names":[],"mappings":"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","sourcesContent":["import { twinLog, twinWarn, twinError } from '../../engine/log.js'\nimport { getRepository } from '@things-factory/shell'\n\nimport { getAdapter, type ConnectionConfig, type LiveFeedContinuity, type SiteDescriptor } from './reference-adapter.js'\nimport { TwinEngine, ingestCanonicalRecords, registerLiveFeedProbe, type CanonicalRecord } from '../../engine/index.js'\nimport { TwinInstance } from '../twin-instance/twin-instance.js'\nimport { TwinReference } from './twin-reference.js'\n\n/*\n * 커넥터 기반 라이브 피드 결선(reference → live, §0 프레임 ① restartPolicy='resync').\n * 흐름: adapter.openLiveFeed(cfg, site, records) → ingestCanonicalRecords(정규→봉투) → TwinEngine.ingestLive(projector 미러).\n * 커넥터가 \"벤더 이벤트 → 정규 레코드\" 매핑을 소유(코드)한다 — preset 기반 startLiveFeed(operato-twin, http-get+jsonata)와\n * 대칭이되, 매핑 위치만 다르다. 실 시스템 전환은 connectionConfig.baseUrl 교체뿐(코드 경로 동일).\n * 생명주기: 인스턴스 정지(TwinEngine.stop)에 훅으로 묶여 피드도 함께 unsubscribe.\n */\n\n/** 활성 라이브 피드 — instanceId → unsubscribe(어댑터 openLiveFeed 반환). */\nconst feeds = new Map<string, () => void>()\n\n/**\n * 커넥터 라이브 기동 — 어댑터 openLiveFeed 를 인스턴스에 결선.\n * 반환: 라이브 결선 성공 여부(어댑터가 openLiveFeed 미지원이면 false → 호출측이 sim 폴백).\n */\nexport async function startReferenceLiveFeed(\n domainId: string,\n adapterType: string,\n cfg: ConnectionConfig,\n site: SiteDescriptor,\n instanceId: string,\n /**\n * **어느 레퍼런스에서 읽는 피드인가** — 읽기 커서를 저장할 자리를 가리킨다(§`loadLiveCursor`).\n *\n * 부르는 쪽이 `TwinReference` 를 이미 들고 있으므로 그 이름을 넘긴다. 처음에는 `cfg`·`site` 에서\n * 찾으려 했는데 **둘 다 그 값을 가질 수 없다**: `cfg` 는 사람이 화면에서 입력한 접속 설정이고\n * (`endpoint`·`token`…), `site` 는 커넥터가 원본에서 발견한 것이다 — **레퍼런스의 이름은 호스트가\n * 붙이는 것이고 원본은 그것을 모른다.** 커넥터가 채울 수 없는 값을 커넥터에서 읽으려 한 것이 잘못이었다.\n *\n * 주지 않으면 커서를 잇지 않고 **그 사실을 말한다**(조용히 창이 미끄러지지 않게).\n */\n source?: string\n): Promise<boolean> {\n const adapter = getAdapter(adapterType)\n if (!adapter?.openLiveFeed) return false\n\n const det = await TwinEngine.detail(domainId, instanceId)\n if (!det?.model) throw new Error(`instance \"${instanceId}\" not provisioned (no model) — ingest master before live start`)\n\n // 관측 기동 — 커널 tick 없이 projector 가 실 이벤트를 반영한다(startLive 가 restartPolicy='resync' 로 적는다).\n // 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.\n TwinEngine.startLive(instanceId, domainId, det.kind, await TwinEngine.withSpaceTimeBase(det.model, domainId))\n\n stopReferenceLiveFeed(instanceId) // 재기동 시 이전 피드 정리\n /*\n * **어디까지 읽었나를 이 층이 들고 건넨다**(§`LiveFeedContinuity`, 2026-08-23).\n *\n * 어댑터가 이 값을 자기 사물함에 두면 원본이 늘 때마다 저장 기제가 늘고, 그중 하나가 조용히 다르게\n * 동작한다. 그리고 커널이 이미 갈라 두었다: 「원천이 애초에 다시 말해 주지 않는 축」은 재기동\n * 연속성으로 이어받는다(§`hydrateContinuity`) — 읽기 커서가 정확히 그 성질이다.\n *\n * 없으면 `undefined` 를 건넨다 — 그것이 「첫 붙음」이라는 사실이고, 그때만 어댑터가 되돌아볼 날수로\n * 첫 창을 만든다. 빈 객체로 메우지 않는다(그러면 첫 붙음과 이어 붙음이 같아진다).\n */\n const continuity = await loadLiveCursor(domainId, instanceId, source)\n const unsubscribe = adapter.openLiveFeed(cfg, site, (records: unknown[]) => {\n if (!TwinEngine.owns(domainId, instanceId)) {\n stopReferenceLiveFeed(instanceId) // 인스턴스 사라지면 자가 정지(안전망)\n return\n }\n const offered = Array.isArray(records) ? records.length : records ? 1 : 0\n const { accepted, rejected } = ingestCanonicalRecords(records as CanonicalRecord[], domainId, new Date().toISOString())\n /*\n * 커널이 **몇 건을 실제로 받았나**를 함께 본다. 트윈이 라이브로 돌지 않으면 `ingestLive` 가 0 을\n * 돌려주고, 그 차이가 곧 「바닥에 버려진 수」다. 이것을 통과로 세면 멈춘 트윈이 100% 로 보인다.\n */\n const applied = accepted.length ? TwinEngine.ingestLive(domainId, instanceId, accepted) : 0\n const undelivered = accepted.length - applied\n /*\n * **거부한 것을 조용히 버리지 않는다.** 예전에는 `rejected` 를 받지도 않았다 — 커넥터의 매핑이\n * 틀리면 트윈은 「아무 일도 일어나지 않는」 모습이 되고, 로그에도 아무 흔적이 없었다.\n *\n * 그다음 자리였던 「화면까지 나르기」를 이제 채운다. 건마다 브로드캐스팅하지 않고 **집계**한다 — 통과분까지\n * 함께 세는 이유는, 거부만 세면 「유입 없음」과 「정상」이 둘 다 거부 0 으로 같아 보이기 때문이다.\n * 조회는 `twinIngestHealth`.\n */\n /*\n * **읽었다는 것 자체가 사실이다** — 빈 읽기도 성공이므로 단절 기록을 지운다(§`onReadFailure`).\n * 그 둘을 섞으면 조용한 원본이 끊긴 원본으로 보인다.\n */\n TwinEngine.clearIngestReadFailure(domainId, instanceId)\n TwinEngine.recordIngestResult(domainId, instanceId, offered, rejected, Date.now(), undelivered)\n if (rejected.length) {\n const why = rejected[0]?.errors?.join(' · ') ?? 'unknown'\n twinWarn(`[twin-live] \"${instanceId}\": ${rejected.length}/${offered} record(s) rejected — e.g. ${why}`)\n }\n /* 버려진 것은 로그에도 남긴다 — 화면을 보지 않는 사람도 알아야 한다(사실이 사라지는 중이다). */\n if (undelivered > 0) {\n twinWarn(\n `[twin-live] \"${instanceId}\": ${undelivered}/${offered} record(s) NOT delivered — the twin is not running live; ` +\n `records are being discarded. Start the twin, or disconnect the feed.`\n )\n }\n }, continuity)\n feeds.set(instanceId, unsubscribe)\n return true\n}\n\n/**\n * 그 원본의 읽기 커서를 불러오고, 움직이면 저장하는 통로를 만든다.\n *\n * 커서는 **원본(`TwinReference`)의 것**이지 인스턴스의 것이 아니다 — 한 원본이 여러 인스턴스를 낳고\n * (`scopeSpec.produced`) 읽는 것은 한 번이다. 인스턴스마다 커서를 두면 같은 표를 여러 번 읽는다.\n *\n * 저장 실패는 **삼키지 않고 말한다.** 조용히 실패하면 다음 재기동에서 창이 다시 미끄러지고, 그 손실은\n * 오류 없이 사라진다 — 이 컬럼을 만든 이유가 바로 그것이다.\n */\nasync function loadLiveCursor(\n domainId: string,\n /** 읽기 실패를 적을 트윈 — 커서는 레퍼런스의 것이지만 **건강은 인스턴스의 것**이다(한 레퍼런스가 여럿을 낳는다). */\n instanceId: string,\n source?: string\n): Promise<LiveFeedContinuity | undefined> {\n if (!source) {\n /* 원본을 가리킬 수 없으면 저장할 자리도 없다 — 그 사실을 말한다(조용히 커서 없이 돌지 않게). */\n twinWarn('[twin-live] live cursor not carried — the caller did not say which reference this feed reads from')\n return undefined\n }\n const repo = getRepository(TwinReference)\n const row = await repo.findOne({ where: { domain: { id: domainId } as any, source } }).catch(() => null)\n if (!row) {\n twinWarn(`[twin-live] live cursor not carried — no reference row for source \"${source}\"`)\n return undefined\n }\n const stored = row.liveCursor\n if (stored?.firstAttachedAt) {\n twinLog(`[twin-live] \"${source}\": resuming from stored cursor — knowledge starts ${stored.firstAttachedAt}`)\n }\n return {\n cursor: stored ?? undefined,\n /*\n * **원본에 닿지 못했다** — 어댑터가 알리면 상태에 적는다(§`onReadFailure`).\n *\n * 로그로는 어댑터가 이미 말하고 있었다(재시도 경고). 그러나 **로그는 사람이 볼 때만 값이 있다** —\n * 화면이 말하려면 조회 가능한 상태에 있어야 한다.\n *\n * ── `status` 를 건드리지 않는다 (2026-08-23, 되돌린 자리) ────────────────────\n * 처음에는 레퍼런스 행의 `status` 를 `'error'` 로도 적었다 — 「목록이 「연결됨」이라 말하면서 실제로는\n * 못 읽는 상태를 없애려고」. **그것이 피드를 영구히 껐다.**\n *\n * 부팅 재부착은 `status: 'connected'` 인 레퍼런스만 되살린다(§`resumeReferenceLiveFeeds`). 그래서\n * 원본이 **잠깐** 끊긴 것이 그 행을 `error` 로 만들고, 다음 재기동에서 그 피드는 아예 붙지 않았다 —\n * 실측으로 그렇게 났다(09:25 판에 `[twin-live]` 줄이 한 줄도 없었다).\n *\n * 두 축을 한 칸에 섞은 것이 원인이다:\n * `status` **연결 설정이 성립하나** — 접속 시험·마스터 동기가 정하는 축(재부착의 자격이다)\n * 읽기 실패 **지금 닿나** — 오갈 수 있는 사실이고, 유입 장부가 그 축을 갖는다\n *\n * 그러니 여기서는 `lastError` 에만 적는다(사람이 목록에서 이유를 볼 수 있게). 「지금 닿나」는\n * `ingestHealth().readFailure` 가 답하고, 성공한 주기가 그것을 지운다.\n */\n onReadFailure: ({ reason, stream }) => {\n TwinEngine.recordIngestReadFailure(domainId, instanceId, reason, Date.now(), stream)\n twinWarn(`[twin-live] \"${source}\": cannot read from the source${stream ? ` (${stream})` : ''} — ${reason}`)\n repo\n .update({ id: row.id }, { lastError: `live: ${reason}` } as any)\n .catch(err => twinWarn(`[twin-live] \"${source}\": read failure not recorded — ${err?.message ?? err}`))\n },\n /**\n * **읽었는데 창을 넘길 수 없다** — 위와 조치가 반대인 사실이다 (2026-08-24).\n *\n * 이 문을 만들기 전에는 이 사실이 `onReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지 못한다」고\n * 말했는데 실제로는 원본이 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다. 사람은 원본을\n * 의심하고 기다리는데 기다림으로는 영원히 풀리지 않는다 — **원인을 반대 방향으로 가리켰다.**\n *\n * `status` 를 건드리지 않는 규율은 위와 같다(재부착의 자격이다). `lastError` 에는 적는다 — 목록에서\n * 이유를 볼 수 있어야 하고, 「닿지 못한다」와 다른 문장이어야 한다.\n */\n onCursorStall: ({ reason, stream }) => {\n TwinEngine.recordIngestCursorStall(domainId, instanceId, reason, Date.now(), stream)\n twinWarn(\n `[twin-live] \"${source}\": read the source but the cursor cannot advance` +\n `${stream ? ` (${stream})` : ''} — ${reason}. Waiting will not clear this.`\n )\n repo\n .update({ id: row.id }, { lastError: `live cursor stalled: ${reason}` } as any)\n .catch(err => twinWarn(`[twin-live] \"${source}\": cursor stall not recorded — ${err?.message ?? err}`))\n },\n onCursor: next => {\n /* 어댑터가 준 모양을 그대로 적는다 — 이 층은 흐름의 뜻을 모른다(열쇠는 어댑터가 정한다). */\n repo\n .update({ id: row.id }, { liveCursor: next } as any)\n .catch(err => twinWarn(`[twin-live] \"${source}\": live cursor not saved — ${err?.message ?? err}`))\n }\n }\n}\n\n/** 라이브 피드 중지(unsubscribe). */\nexport function stopReferenceLiveFeed(instanceId: string): boolean {\n const unsub = feeds.get(instanceId)\n if (!unsub) return false\n try { unsub() } catch { /* noop */ }\n feeds.delete(instanceId)\n return true\n}\n\n/** 활성 피드 목록(관측). */\nexport function listReferenceLiveFeeds(): string[] {\n return [...feeds.keys()]\n}\n\n/**\n * 부팅 때 **끊어진 피드를 다시 붙인다** — 도는 미러 트윈에 계측이 계속 들어오게.\n *\n * ── 무엇이 조용히 끊겼나 (2026-08-14) ───────────────────────────────────────\n * `startReferenceLiveFeed` 를 부르는 곳은 `importTwinReference` **하나뿐**이었다. 그래서 서버를 한 번\n * 재기동하면 미러 트윈은 「도는 중」이라 말하면서 아무 계측도 받지 않았다 — 오류도 로그도 없이,\n * 사람은 「값이 안 변하네」로 알게 된다. 실 배포에서 재기동 한 번에 계측이 멈추는 셈이다.\n *\n * ── 되살릴 재료는 이미 영속돼 있다 ─────────────────────────────────────────\n * `TwinReference` 가 어댑터 종류·접속 설정·그 레퍼런스가 만든 인스턴스(`scopeSpec.produced`)를 들고 있다.\n * 사이트 서술자만 없는데(`hint` 는 남기지 않았다) **다시 찾아서** 얻는다(`discoverSites`) — 인제스트가\n * 걷는 것과 같은 길이므로 규칙이 갈라지지 않는다. 되살릴 인스턴스가 없으면 그 호출도 하지 않는다.\n *\n * 재개 대상은 **등록부가 도는 미러라고 말하는 것**뿐이다: 멈춘 트윈을 되살리면 사람이 멈춘 것을\n * 우리가 다시 켜는 것이고, 시뮬 트윈에 실 계측을 부으면 두 현실이 섞인다.\n */\nexport async function resumeReferenceLiveFeeds(): Promise<{ resumed: string[]; failed: { instanceId: string; error: string }[] }> {\n const resumed: string[] = []\n const failed: { instanceId: string; error: string }[] = []\n /*\n * **한 트윈에 피드는 하나다** (2026-08-18).\n *\n * 같은 사이트를 두 레퍼런스가 각각 인제스트하면 둘 다 그 인스턴스를 자기가 만든 것(`produced`)으로\n * 들고 있다. 기동 때 그 목록을 그대로 돌면 같은 트윈에 피드를 두 번 붙이는데, 붙이는 함수가 미러를\n * 다시 세우므로(`startLive`) **먼저 쌓인 수요 구간이 리셋된다** — 실제로 그렇게 났다\n * (로그: `reattached 2 feed(s): ems-plant-1, ems-plant-1`).\n *\n * 그러니 한 번만 붙이고, **두 레퍼런스가 같은 트윈을 주장한다는 사실은 말한다** — 조용히 하나를\n * 고르면 어느 접속이 그 트윈을 먹이고 있는지 아무도 모르게 된다.\n */\n const attached = new Map<string, string>() // instanceId → 먼저 붙인 레퍼런스\n try {\n const refs = await getRepository(TwinReference).find({ where: { status: 'connected' }, relations: ['domain'] })\n for (const ref of refs) {\n const domainId = (ref as any).domain?.id ?? (ref as any).domainId\n const adapter = getAdapter(ref.adapterType)\n if (!domainId || !adapter?.openLiveFeed) continue // 관측만 하는 어댑터에는 되살릴 피드가 없다\n\n const produced: any[] = ((ref.scopeSpec as any)?.produced ?? []).filter((p: any) => p?.instanceId && p?.siteId)\n if (!produced.length) continue\n\n /* 등록부가 「도는 미러」라고 말하는 것만 고른다. */\n const rows = await getRepository(TwinInstance).find({\n where: produced.map((p: any) => ({ domain: { id: domainId }, instanceId: p.instanceId })) as any\n })\n const live = produced.filter((p: any) =>\n rows.some(r => r.instanceId === p.instanceId && r.status === 'running' && r.restartPolicy === 'resync')\n )\n if (!live.length) continue\n\n let sites: SiteDescriptor[]\n try {\n sites = await adapter.discoverSites(ref.connectionConfig ?? {})\n } catch (e: any) {\n /* 원 시스템이 아직 안 떴을 수 있다 — 지어내지 않고 그렇게 적는다(트윈은 계속 「도는 미러」다). */\n for (const p of live) failed.push({ instanceId: p.instanceId, error: `discover failed: ${e?.message ?? 'unknown'}` })\n continue\n }\n\n for (const p of live) {\n const site = sites.find(s => s.siteId === p.siteId)\n if (!site) {\n failed.push({ instanceId: p.instanceId, error: `site \"${p.siteId}\" no longer discoverable` })\n continue\n }\n const owner = attached.get(p.instanceId)\n if (owner) {\n /* 두 접속이 같은 트윈을 주장한다 — 먼저 붙은 것을 지키고, 그 사실을 남긴다(둘 다 붙이면 미러가 리셋된다). */\n failed.push({\n instanceId: p.instanceId,\n error: `also claimed by reference \"${ref.source}\" — kept the feed from \"${owner}\" (two connections feeding one twin would reset the mirror)`\n })\n continue\n }\n attached.set(p.instanceId, ref.source)\n try {\n const ok = await startReferenceLiveFeed(domainId, ref.adapterType, ref.connectionConfig ?? {}, site, p.instanceId, ref.source)\n if (ok) resumed.push(p.instanceId)\n else failed.push({ instanceId: p.instanceId, error: 'adapter has no openLiveFeed' })\n } catch (e: any) {\n failed.push({ instanceId: p.instanceId, error: e?.message ?? 'reattach failed' })\n }\n }\n }\n } catch (e: any) {\n twinError('[twin-live] feed resume scan failed', e?.message)\n }\n\n if (resumed.length) twinLog(`[twin-live] reattached ${resumed.length} feed(s): ${resumed.join(', ')}`)\n /* **조용히 넘기지 않는다** — 붙지 않은 미러는 「도는데 아무것도 오지 않는」 트윈이다. */\n for (const f of failed) twinWarn(`[twin-live] \"${f.instanceId}\" feed NOT reattached — ${f.error}`)\n return { resumed, failed }\n}\n\n/*\n * 이 계층의 피드 장부를 등록한다 — 화면이 「끊김」을 말할 근거는 **묻는 자리 하나**여야 한다.\n * (프리셋 기반 피드는 앱이 자기 장부를 따로 등록한다. 한쪽만 보면 흐르는 트윈을 끊겼다고 부른다.)\n */\nregisterLiveFeedProbe(() => [...feeds.keys()])\n\n/* 인스턴스 정지 시 피드 자동 정리(생명주기 대칭). */\nTwinEngine.onStop(instanceId => stopReferenceLiveFeed(instanceId))\n"]}
1
+ {"version":3,"file":"reference-live.js","sourceRoot":"","sources":["../../../server/service/reference/reference-live.ts"],"names":[],"mappings":"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","sourcesContent":["import { twinLog, twinWarn, twinError } from '../../engine/log.js'\nimport { getRepository } from '@things-factory/shell'\n\nimport { getAdapter, type ConnectionConfig, type LiveFeedContinuity, type SiteDescriptor } from './reference-adapter.js'\nimport { TwinEngine, ingestCanonicalRecords, registerLiveFeedProbe, type CanonicalRecord } from '../../engine/index.js'\nimport { TwinInstance } from '../twin-instance/twin-instance.js'\nimport { TwinReference } from './twin-reference.js'\n\n/*\n * 커넥터 기반 라이브 피드 결선(reference → live, §0 프레임 ① restartPolicy='resync').\n * 흐름: adapter.openLiveFeed(cfg, site, records) → ingestCanonicalRecords(정규→봉투) → TwinEngine.ingestLive(projector 미러).\n * 커넥터가 \"벤더 이벤트 → 정규 레코드\" 매핑을 소유(코드)한다 — preset 기반 startLiveFeed(operato-twin, http-get+jsonata)와\n * 대칭이되, 매핑 위치만 다르다. 실 시스템 전환은 connectionConfig.baseUrl 교체뿐(코드 경로 동일).\n * 생명주기: 인스턴스 정지(TwinEngine.stop)에 훅으로 묶여 피드도 함께 unsubscribe.\n */\n\n/** 활성 라이브 피드 — instanceId → unsubscribe(어댑터 openLiveFeed 반환). */\nconst feeds = new Map<string, () => void>()\n\n/**\n * 커넥터 라이브 기동 — 어댑터 openLiveFeed 를 인스턴스에 결선.\n * 반환: 라이브 결선 성공 여부(어댑터가 openLiveFeed 미지원이면 false → 호출측이 sim 폴백).\n */\nexport async function startReferenceLiveFeed(\n domainId: string,\n adapterType: string,\n cfg: ConnectionConfig,\n site: SiteDescriptor,\n instanceId: string,\n /**\n * **어느 레퍼런스에서 읽는 피드인가** — 읽기 커서를 저장할 자리를 가리킨다(§`loadLiveCursor`).\n *\n * 부르는 쪽이 `TwinReference` 를 이미 들고 있으므로 그 이름을 넘긴다. 처음에는 `cfg`·`site` 에서\n * 찾으려 했는데 **둘 다 그 값을 가질 수 없다**: `cfg` 는 사람이 화면에서 입력한 접속 설정이고\n * (`endpoint`·`token`…), `site` 는 커넥터가 원본에서 발견한 것이다 — **레퍼런스의 이름은 호스트가\n * 붙이는 것이고 원본은 그것을 모른다.** 커넥터가 채울 수 없는 값을 커넥터에서 읽으려 한 것이 잘못이었다.\n *\n * 주지 않으면 커서를 잇지 않고 **그 사실을 말한다**(조용히 창이 미끄러지지 않게).\n */\n source?: string\n): Promise<boolean> {\n const adapter = getAdapter(adapterType)\n if (!adapter?.openLiveFeed) return false\n\n const det = await TwinEngine.detail(domainId, instanceId)\n if (!det?.model) throw new Error(`instance \"${instanceId}\" not provisioned (no model) — ingest master before live start`)\n\n // 관측 기동 — 커널 tick 없이 projector 가 실 이벤트를 반영한다(startLive 가 restartPolicy='resync' 로 적는다).\n // 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.\n TwinEngine.startLive(instanceId, domainId, det.kind, await TwinEngine.withSpaceTimeBase(det.model, domainId))\n\n stopReferenceLiveFeed(instanceId) // 재기동 시 이전 피드 정리\n /*\n * **어디까지 읽었나를 이 층이 들고 건넨다**(§`LiveFeedContinuity`, 2026-08-23).\n *\n * 어댑터가 이 값을 자기 사물함에 두면 원본이 늘 때마다 저장 기제가 늘고, 그중 하나가 조용히 다르게\n * 동작한다. 그리고 커널이 이미 갈라 두었다: 「원천이 애초에 다시 말해 주지 않는 축」은 재기동\n * 연속성으로 이어받는다(§`hydrateContinuity`) — 읽기 커서가 정확히 그 성질이다.\n *\n * 없으면 `undefined` 를 건넨다 — 그것이 「첫 붙음」이라는 사실이고, 그때만 어댑터가 되돌아볼 날수로\n * 첫 창을 만든다. 빈 객체로 메우지 않는다(그러면 첫 붙음과 이어 붙음이 같아진다).\n */\n const continuity = await loadLiveCursor(domainId, instanceId, source)\n const unsubscribe = adapter.openLiveFeed(cfg, site, (records: unknown[]) => {\n if (!TwinEngine.owns(domainId, instanceId)) {\n stopReferenceLiveFeed(instanceId) // 인스턴스 사라지면 자가 정지(안전망)\n return\n }\n const offered = Array.isArray(records) ? records.length : records ? 1 : 0\n const { accepted, rejected } = ingestCanonicalRecords(records as CanonicalRecord[], domainId, new Date().toISOString())\n /*\n * 커널이 **몇 건을 실제로 받았나**를 함께 본다. 트윈이 라이브로 돌지 않으면 `ingestLive` 가 0 을\n * 돌려주고, 그 차이가 곧 「바닥에 버려진 수」다. 이것을 통과로 세면 멈춘 트윈이 100% 로 보인다.\n */\n const applied = accepted.length ? TwinEngine.ingestLive(domainId, instanceId, accepted) : 0\n const undelivered = accepted.length - applied\n /*\n * **거부한 것을 조용히 버리지 않는다.** 예전에는 `rejected` 를 받지도 않았다 — 커넥터의 매핑이\n * 틀리면 트윈은 「아무 일도 일어나지 않는」 모습이 되고, 로그에도 아무 흔적이 없었다.\n *\n * 그다음 자리였던 「화면까지 나르기」를 이제 채운다. 건마다 브로드캐스팅하지 않고 **집계**한다 — 통과분까지\n * 함께 세는 이유는, 거부만 세면 「유입 없음」과 「정상」이 둘 다 거부 0 으로 같아 보이기 때문이다.\n * 조회는 `twinIngestHealth`.\n */\n /*\n * **읽었다는 것 자체가 사실이다** — 빈 읽기도 성공이므로 단절 기록을 지운다(§`onReadFailure`).\n * 그 둘을 섞으면 조용한 원본이 끊긴 원본으로 보인다.\n */\n TwinEngine.clearIngestReadFailure(domainId, instanceId)\n TwinEngine.recordIngestResult(domainId, instanceId, offered, rejected, Date.now(), undelivered)\n if (rejected.length) {\n const why = rejected[0]?.errors?.join(' · ') ?? 'unknown'\n twinWarn(`[twin-live] \"${instanceId}\": ${rejected.length}/${offered} record(s) rejected — e.g. ${why}`)\n }\n /* 버려진 것은 로그에도 남긴다 — 화면을 보지 않는 사람도 알아야 한다(사실이 사라지는 중이다). */\n if (undelivered > 0) {\n twinWarn(\n `[twin-live] \"${instanceId}\": ${undelivered}/${offered} record(s) NOT delivered — the twin is not running live; ` +\n `records are being discarded. Start the twin, or disconnect the feed.`\n )\n }\n }, continuity)\n feeds.set(instanceId, unsubscribe)\n return true\n}\n\n/**\n * 그 원본의 읽기 커서를 불러오고, 움직이면 저장하는 통로를 만든다.\n *\n * 커서는 **원본(`TwinReference`)의 것**이지 인스턴스의 것이 아니다 — 한 원본이 여러 인스턴스를 낳고\n * (`scopeSpec.produced`) 읽는 것은 한 번이다. 인스턴스마다 커서를 두면 같은 표를 여러 번 읽는다.\n *\n * 저장 실패는 **삼키지 않고 말한다.** 조용히 실패하면 다음 재기동에서 창이 다시 미끄러지고, 그 손실은\n * 오류 없이 사라진다 — 이 컬럼을 만든 이유가 바로 그것이다.\n */\nasync function loadLiveCursor(\n domainId: string,\n /** 읽기 실패를 적을 트윈 — 커서는 레퍼런스의 것이지만 **건강은 인스턴스의 것**이다(한 레퍼런스가 여럿을 낳는다). */\n instanceId: string,\n source?: string\n): Promise<LiveFeedContinuity | undefined> {\n if (!source) {\n /* 원본을 가리킬 수 없으면 저장할 자리도 없다 — 그 사실을 말한다(조용히 커서 없이 돌지 않게). */\n twinWarn('[twin-live] live cursor not carried — the caller did not say which reference this feed reads from')\n return undefined\n }\n const repo = getRepository(TwinReference)\n const row = await repo.findOne({ where: { domain: { id: domainId } as any, source } }).catch(() => null)\n if (!row) {\n twinWarn(`[twin-live] live cursor not carried — no reference row for source \"${source}\"`)\n return undefined\n }\n const stored = row.liveCursor\n if (stored?.firstAttachedAt) {\n twinLog(`[twin-live] \"${source}\": resuming from stored cursor — knowledge starts ${stored.firstAttachedAt}`)\n }\n return {\n cursor: stored ?? undefined,\n /*\n * **원본에 닿지 못했다** — 어댑터가 알리면 상태에 적는다(§`onReadFailure`).\n *\n * 로그로는 어댑터가 이미 말하고 있었다(재시도 경고). 그러나 **로그는 사람이 볼 때만 값이 있다** —\n * 화면이 말하려면 조회 가능한 상태에 있어야 한다.\n *\n * ── `status` 를 건드리지 않는다 (2026-08-23, 되돌린 자리) ────────────────────\n * 처음에는 레퍼런스 행의 `status` 를 `'error'` 로도 적었다 — 「목록이 「연결됨」이라 말하면서 실제로는\n * 못 읽는 상태를 없애려고」. **그것이 피드를 영구히 껐다.**\n *\n * 부팅 재부착은 `status: 'connected'` 인 레퍼런스만 되살린다(§`resumeReferenceLiveFeeds`). 그래서\n * 원본이 **잠깐** 끊긴 것이 그 행을 `error` 로 만들고, 다음 재기동에서 그 피드는 아예 붙지 않았다 —\n * 실측으로 그렇게 났다(09:25 판에 `[twin-live]` 줄이 한 줄도 없었다).\n *\n * 두 축을 한 칸에 섞은 것이 원인이다:\n * `status` **연결 설정이 성립하나** — 접속 시험·마스터 동기가 정하는 축(재부착의 자격이다)\n * 읽기 실패 **지금 닿나** — 오갈 수 있는 사실이고, 유입 장부가 그 축을 갖는다\n *\n * 그러니 여기서는 `lastError` 에만 적는다(사람이 목록에서 이유를 볼 수 있게). 「지금 닿나」는\n * `ingestHealth().readFailure` 가 답하고, 성공한 주기가 그것을 지운다.\n */\n onReadFailure: ({ reason, stream }) => {\n TwinEngine.recordIngestReadFailure(domainId, instanceId, reason, Date.now(), stream)\n twinWarn(`[twin-live] \"${source}\": cannot read from the source${stream ? ` (${stream})` : ''} — ${reason}`)\n repo\n .update({ id: row.id }, { lastError: `live: ${reason}` } as any)\n .catch(err => twinWarn(`[twin-live] \"${source}\": read failure not recorded — ${err?.message ?? err}`))\n },\n /**\n * **읽었는데 창을 넘길 수 없다** — 위와 조치가 반대인 사실이다 (2026-08-24).\n *\n * 이 문을 만들기 전에는 이 사실이 `onReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지 못한다」고\n * 말했는데 실제로는 원본이 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다. 사람은 원본을\n * 의심하고 기다리는데 기다림으로는 영원히 풀리지 않는다 — **원인을 반대 방향으로 가리켰다.**\n *\n * `status` 를 건드리지 않는 규율은 위와 같다(재부착의 자격이다). `lastError` 에는 적는다 — 목록에서\n * 이유를 볼 수 있어야 하고, 「닿지 못한다」와 다른 문장이어야 한다.\n */\n /*\n * **세우지 않은 것을 장부에 적는다** — 원본에 있는데 트윈에 세우지 않은 것(이유와 수).\n *\n * 로그로도 남기지만 **로그는 사람이 볼 때만 값이 있다.** 이 사실은 상태의 성질이라(세우지 않은 것은\n * 지금도 없다) 조회되는 자리에 있어야 한다 — 그러지 않으면 프로비저닝 화면에서 한 번 스쳐 지나간다.\n *\n * `status`·`lastError` 를 건드리지 않는다: 이것은 **실패가 아니다.** 어댑터가 옳은 판단으로 안 받은\n * 것이고, 오류로 적으면 화면이 원본을 의심하게 만든다.\n */\n onWithheld: ({ reason, count }) => {\n TwinEngine.recordIngestWithheld(domainId, instanceId, reason, count)\n },\n onCursorStall: ({ reason, stream }) => {\n TwinEngine.recordIngestCursorStall(domainId, instanceId, reason, Date.now(), stream)\n twinWarn(\n `[twin-live] \"${source}\": read the source but the cursor cannot advance` +\n `${stream ? ` (${stream})` : ''} — ${reason}. Waiting will not clear this.`\n )\n repo\n .update({ id: row.id }, { lastError: `live cursor stalled: ${reason}` } as any)\n .catch(err => twinWarn(`[twin-live] \"${source}\": cursor stall not recorded — ${err?.message ?? err}`))\n },\n onCursor: next => {\n /* 어댑터가 준 모양을 그대로 적는다 — 이 층은 흐름의 뜻을 모른다(열쇠는 어댑터가 정한다). */\n repo\n .update({ id: row.id }, { liveCursor: next } as any)\n .catch(err => twinWarn(`[twin-live] \"${source}\": live cursor not saved — ${err?.message ?? err}`))\n }\n }\n}\n\n/** 라이브 피드 중지(unsubscribe). */\nexport function stopReferenceLiveFeed(instanceId: string): boolean {\n const unsub = feeds.get(instanceId)\n if (!unsub) return false\n try { unsub() } catch { /* noop */ }\n feeds.delete(instanceId)\n return true\n}\n\n/** 활성 피드 목록(관측). */\nexport function listReferenceLiveFeeds(): string[] {\n return [...feeds.keys()]\n}\n\n/**\n * 부팅 때 **끊어진 피드를 다시 붙인다** — 도는 미러 트윈에 계측이 계속 들어오게.\n *\n * ── 무엇이 조용히 끊겼나 (2026-08-14) ───────────────────────────────────────\n * `startReferenceLiveFeed` 를 부르는 곳은 `importTwinReference` **하나뿐**이었다. 그래서 서버를 한 번\n * 재기동하면 미러 트윈은 「도는 중」이라 말하면서 아무 계측도 받지 않았다 — 오류도 로그도 없이,\n * 사람은 「값이 안 변하네」로 알게 된다. 실 배포에서 재기동 한 번에 계측이 멈추는 셈이다.\n *\n * ── 되살릴 재료는 이미 영속돼 있다 ─────────────────────────────────────────\n * `TwinReference` 가 어댑터 종류·접속 설정·그 레퍼런스가 만든 인스턴스(`scopeSpec.produced`)를 들고 있다.\n * 사이트 서술자만 없는데(`hint` 는 남기지 않았다) **다시 찾아서** 얻는다(`discoverSites`) — 인제스트가\n * 걷는 것과 같은 길이므로 규칙이 갈라지지 않는다. 되살릴 인스턴스가 없으면 그 호출도 하지 않는다.\n *\n * 재개 대상은 **등록부가 도는 미러라고 말하는 것**뿐이다: 멈춘 트윈을 되살리면 사람이 멈춘 것을\n * 우리가 다시 켜는 것이고, 시뮬 트윈에 실 계측을 부으면 두 현실이 섞인다.\n */\nexport async function resumeReferenceLiveFeeds(): Promise<{ resumed: string[]; failed: { instanceId: string; error: string }[] }> {\n const resumed: string[] = []\n const failed: { instanceId: string; error: string }[] = []\n /*\n * **한 트윈에 피드는 하나다** (2026-08-18).\n *\n * 같은 사이트를 두 레퍼런스가 각각 인제스트하면 둘 다 그 인스턴스를 자기가 만든 것(`produced`)으로\n * 들고 있다. 기동 때 그 목록을 그대로 돌면 같은 트윈에 피드를 두 번 붙이는데, 붙이는 함수가 미러를\n * 다시 세우므로(`startLive`) **먼저 쌓인 수요 구간이 리셋된다** — 실제로 그렇게 났다\n * (로그: `reattached 2 feed(s): ems-plant-1, ems-plant-1`).\n *\n * 그러니 한 번만 붙이고, **두 레퍼런스가 같은 트윈을 주장한다는 사실은 말한다** — 조용히 하나를\n * 고르면 어느 접속이 그 트윈을 먹이고 있는지 아무도 모르게 된다.\n */\n const attached = new Map<string, string>() // instanceId → 먼저 붙인 레퍼런스\n try {\n const refs = await getRepository(TwinReference).find({ where: { status: 'connected' }, relations: ['domain'] })\n for (const ref of refs) {\n const domainId = (ref as any).domain?.id ?? (ref as any).domainId\n const adapter = getAdapter(ref.adapterType)\n if (!domainId || !adapter?.openLiveFeed) continue // 관측만 하는 어댑터에는 되살릴 피드가 없다\n\n const produced: any[] = ((ref.scopeSpec as any)?.produced ?? []).filter((p: any) => p?.instanceId && p?.siteId)\n if (!produced.length) continue\n\n /* 등록부가 「도는 미러」라고 말하는 것만 고른다. */\n const rows = await getRepository(TwinInstance).find({\n where: produced.map((p: any) => ({ domain: { id: domainId }, instanceId: p.instanceId })) as any\n })\n const live = produced.filter((p: any) =>\n rows.some(r => r.instanceId === p.instanceId && r.status === 'running' && r.restartPolicy === 'resync')\n )\n if (!live.length) continue\n\n let sites: SiteDescriptor[]\n try {\n sites = await adapter.discoverSites(ref.connectionConfig ?? {})\n } catch (e: any) {\n /* 원 시스템이 아직 안 떴을 수 있다 — 지어내지 않고 그렇게 적는다(트윈은 계속 「도는 미러」다). */\n for (const p of live) failed.push({ instanceId: p.instanceId, error: `discover failed: ${e?.message ?? 'unknown'}` })\n continue\n }\n\n for (const p of live) {\n const site = sites.find(s => s.siteId === p.siteId)\n if (!site) {\n failed.push({ instanceId: p.instanceId, error: `site \"${p.siteId}\" no longer discoverable` })\n continue\n }\n const owner = attached.get(p.instanceId)\n if (owner) {\n /* 두 접속이 같은 트윈을 주장한다 — 먼저 붙은 것을 지키고, 그 사실을 남긴다(둘 다 붙이면 미러가 리셋된다). */\n failed.push({\n instanceId: p.instanceId,\n error: `also claimed by reference \"${ref.source}\" — kept the feed from \"${owner}\" (two connections feeding one twin would reset the mirror)`\n })\n continue\n }\n attached.set(p.instanceId, ref.source)\n try {\n const ok = await startReferenceLiveFeed(domainId, ref.adapterType, ref.connectionConfig ?? {}, site, p.instanceId, ref.source)\n if (ok) resumed.push(p.instanceId)\n else failed.push({ instanceId: p.instanceId, error: 'adapter has no openLiveFeed' })\n } catch (e: any) {\n failed.push({ instanceId: p.instanceId, error: e?.message ?? 'reattach failed' })\n }\n }\n }\n } catch (e: any) {\n twinError('[twin-live] feed resume scan failed', e?.message)\n }\n\n if (resumed.length) twinLog(`[twin-live] reattached ${resumed.length} feed(s): ${resumed.join(', ')}`)\n /* **조용히 넘기지 않는다** — 붙지 않은 미러는 「도는데 아무것도 오지 않는」 트윈이다. */\n for (const f of failed) twinWarn(`[twin-live] \"${f.instanceId}\" feed NOT reattached — ${f.error}`)\n return { resumed, failed }\n}\n\n/*\n * 이 계층의 피드 장부를 등록한다 — 화면이 「끊김」을 말할 근거는 **묻는 자리 하나**여야 한다.\n * (프리셋 기반 피드는 앱이 자기 장부를 따로 등록한다. 한쪽만 보면 흐르는 트윈을 끊겼다고 부른다.)\n */\nregisterLiveFeedProbe(() => [...feeds.keys()])\n\n/* 인스턴스 정지 시 피드 자동 정리(생명주기 대칭). */\nTwinEngine.onStop(instanceId => stopReferenceLiveFeed(instanceId))\n"]}
@@ -17,7 +17,27 @@ export declare function bizStepOf(envelope: any): string | undefined;
17
17
  * URN 으로 찾는 경로가 사라진다.
18
18
  */
19
19
  export declare function epcOf(envelope: any): string | undefined;
20
- /** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
20
+ /**
21
+ * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.
22
+ *
23
+ * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────
24
+ * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다
25
+ * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도
26
+ * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.
27
+ *
28
+ * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)
29
+ * task.status 191,175 행 — 비어 있음 191,175 (100%)
30
+ *
31
+ * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수
32
+ * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —
33
+ * 시점을 어디로 옮겨도 0 이었다.
34
+ *
35
+ * 순서가 중요하다: 운영 델타가 먼저다. EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와
36
+ * 같은 것이 아닐 수 있으므로, 오더를 스스로 말하는 사건은 그 말을 그대로 쓴다.
37
+ *
38
+ * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는
39
+ * 사건부터 조회된다.
40
+ */
21
41
  export declare function orderOf(envelope: any): string | undefined;
22
42
  /**
23
43
  * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).
@@ -43,10 +43,30 @@ function epcOf(envelope) {
43
43
  const d = envelope?.data ?? envelope ?? {};
44
44
  return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined;
45
45
  }
46
- /** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
46
+ /**
47
+ * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.
48
+ *
49
+ * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────
50
+ * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다
51
+ * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도
52
+ * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.
53
+ *
54
+ * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)
55
+ * task.status 191,175 행 — 비어 있음 191,175 (100%)
56
+ *
57
+ * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수
58
+ * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —
59
+ * 시점을 어디로 옮겨도 0 이었다.
60
+ *
61
+ * 순서가 중요하다: 운영 델타가 먼저다. EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와
62
+ * 같은 것이 아닐 수 있으므로, 오더를 스스로 말하는 사건은 그 말을 그대로 쓴다.
63
+ *
64
+ * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는
65
+ * 사건부터 조회된다.
66
+ */
47
67
  function orderOf(envelope) {
48
68
  const d = envelope?.data ?? envelope ?? {};
49
- return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? undefined;
69
+ return d.orderId ?? d.bizTransactionList?.[0]?.bizTransaction ?? undefined;
50
70
  }
51
71
  /**
52
72
  * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).
@@ -65,7 +85,19 @@ function locationOf(envelope) {
65
85
  */
66
86
  function equipmentIdOf(envelope) {
67
87
  const d = envelope?.data ?? envelope ?? {};
68
- return d.moverId ?? undefined;
88
+ /*
89
+ * ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ────────────────────────────
90
+ * 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을
91
+ * 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부
92
+ * 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.**
93
+ *
94
+ * 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴
95
+ * 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를
96
+ * 설비 축으로 물을 수 없었다.
97
+ *
98
+ * 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다.
99
+ */
100
+ return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined;
69
101
  }
70
102
  /**
71
103
  * EPCIS 행위 — `ADD`·`OBSERVE`·`DELETE`. 세 값이 아니면 `undefined`(원천의 잡값을 색인에 넣지 않는다).
@@ -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 locationId?: string\n moverId?: 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/** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */\nexport function orderOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? 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 return d.moverId ?? 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/** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */\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 locationId: clip(locationOf(envelope), 'locationId'),\n moverId: clip(equipmentIdOf(envelope), 'moverId')\n }\n}\n"]}
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 locationId?: string\n moverId?: 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 * 순서가 중요하다: 운영 델타가 먼저다. EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와\n * 같은 것이 아닐 수 있으므로, 오더를 스스로 말하는 사건은 그 말을 그대로 쓴다.\n *\n * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는\n * 사건부터 조회된다.\n */\nexport function orderOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.orderId ?? d.bizTransactionList?.[0]?.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/** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */\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 locationId: clip(locationOf(envelope), 'locationId'),\n moverId: clip(equipmentIdOf(envelope), 'moverId')\n }\n}\n"]}
@@ -145,11 +145,15 @@ const FIELDS = [
145
145
  note: 'twin.epcis.note.ilmd'
146
146
  },
147
147
  {
148
+ /*
149
+ * 2026-08-24 — **`shownOn` 을 걷었다.** 같은 줄의 `note` 가 「화면에 보이는 곳은 아직 없습니다」라고
150
+ * 말하는데 배지는 「물품에서 표시됩니다」라고 말하고 있었다 — 한 줄이 서로 반대를 말했다.
151
+ * `surface: 'none'` 이면 갈 곳을 적지 않는다(§`shownOn` 규칙).
152
+ */
148
153
  std: 'errorDeclaration',
149
154
  part: 'fields',
150
155
  label: 'twin.epcis.errorDeclaration',
151
156
  axis: null,
152
- shownOn: ['items'],
153
157
  structure: 'full',
154
158
  behavior: 'partial',
155
159
  surface: 'none',
@@ -1 +1 @@
1
- {"version":3,"file":"epcis-coverage.js","sourceRoot":"","sources":["../../../server/service/twin-model/epcis-coverage.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * GS1 EPCIS 2.0 적합성 기준 — **물류·제조 트윈이 표준의 어디를 채우고 있나.**\n *\n * ── 왜 따로 있나 ────────────────────────────────────────────────────────────\n * 적합성 패널이 ISA-95 하나만 말하고 있었다. 그런데 창고·야드·공장 트윈에서 「무슨 일이 있었나」와\n * 「이 번호가 무엇을 가리키나」를 정하는 것은 EPCIS 다. ISA-95 표만 보이면 그 트윈이 기대는 기준의\n * 절반이 화면에 없다.\n *\n * ── 수준은 근거를 보고 적었다 ───────────────────────────────────────────────\n * 「있다/없다」를 기억으로 적지 않는다. 각 줄의 판단 근거:\n * · 타입 선언 — 커널 `epcis.ts`(이벤트·필드의 모양)\n * · 실제 사용 — 커널 도메인(`mes-kernel`·`face2-adapter`·`observed-reducer`)과 호스트 인제스트\n * · 화면 노출 — 저널·계보·엔티티 360\n * 모양만 있고 아무도 채우지 않는 필드는 **구조만 있음**으로 적는다(그게 사실이다).\n *\n * ISA-95 표와 같은 규율이다(`isa95-coverage.ts` 머리말): 사람이 판단해 적고, 틀리면 여기를 고친다.\n */\nimport { reconcileAxes, type Isa95Concept, type CoverageLevel } from './isa95-coverage.js'\n\n/* Part: EPCIS 2.0 의 사건 종류 — 무엇이 일어났는가. */\nconst EVENTS: Isa95Concept[] = [\n {\n std: 'ObjectEvent',\n part: 'events',\n label: 'twin.epcis.ObjectEvent',\n axis: 'items',\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.ObjectEvent'\n },\n {\n std: 'AggregationEvent',\n part: 'events',\n label: 'twin.epcis.AggregationEvent',\n axis: null,\n shownOn: ['items'],\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.AggregationEvent'\n },\n {\n std: 'TransactionEvent',\n part: 'events',\n label: 'twin.epcis.TransactionEvent',\n axis: 'orders',\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.TransactionEvent'\n },\n {\n std: 'TransformationEvent',\n part: 'events',\n label: 'twin.epcis.TransformationEvent',\n axis: null,\n shownOn: ['items', 'operations'],\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.TransformationEvent'\n },\n /* EPCIS 2.0 이 더한 사건 — 우리는 타입조차 두지 않았다. 없는 것은 없다고 적는다. */\n {\n std: 'AssociationEvent',\n part: 'events',\n label: 'twin.epcis.AssociationEvent',\n axis: null,\n structure: 'none',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.AssociationEvent'\n }\n]\n\n/* Part: 사건이 답하는 다섯 가지 — 무엇을·어디서·언제·왜, 그리고 어떤 거래로. */\nconst FIELDS: Isa95Concept[] = [\n {\n std: 'epcList / quantityList',\n part: 'fields',\n label: 'twin.epcis.identity',\n axis: 'items',\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.identity'\n },\n /*\n * ── 2026-08-24 주장 근거가 달라졌다 ────────────────────────────────────────\n * 이 줄은 오래 `full` 이었지만 근거가 **우리 선언**이었다. 이제 **정본 원문 대조**다:\n * CBV Standard Release 2.0(Ratified Jun 2022) §7.2.3 의 처분 38개를 전수 확인했다.\n *\n * 그 대조가 실제로 셋을 바꿨다.\n * · `expired` — 있는 낱말인데 우리가 쓰지 않고 있었다(기한 경과를 처분으로 말할 수 있게 됐다)\n * · `conformant` / `non_conformant` — 검사 판정을 처분으로 남기는 낱말. 함께 `inspecting` bizStep\n * · `non_sellable_expired` 는 표준이 **폐기**하고 `expired` 로 대체한 것을 확인\n *\n * ★ 그리고 **검증 방법에 함정이 있다**: `ref.gs1.org/cbv/…` 로 URN 을 조회하면 **지어낸 값에도\n * 똑같은 응답**이 온다. 그것으로 확인했다고 여기면 없는 낱말을 발행한다(한 번 그렇게 했다).\n * 확인은 정본 문서로만 한다.\n */\n {\n std: 'bizStep / disposition (CBV)',\n part: 'fields',\n label: 'twin.epcis.cbv',\n axis: null,\n shownOn: ['items', 'tasks'],\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.cbv'\n },\n {\n std: 'readPoint / bizLocation',\n part: 'fields',\n label: 'twin.epcis.where',\n axis: 'locations',\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.where'\n },\n {\n std: 'bizTransactionList',\n part: 'fields',\n label: 'twin.epcis.bizTransaction',\n axis: 'orders',\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.bizTransaction'\n },\n {\n std: 'ilmd',\n part: 'fields',\n label: 'twin.epcis.ilmd',\n axis: null,\n shownOn: ['items'],\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.ilmd'\n },\n {\n std: 'errorDeclaration',\n part: 'fields',\n label: 'twin.epcis.errorDeclaration',\n axis: null,\n shownOn: ['items'],\n structure: 'full',\n behavior: 'partial',\n surface: 'none',\n note: 'twin.epcis.note.errorDeclaration'\n },\n /* 모양만 있고 아무도 채우지 않는 것들 — 구조만 있음. */\n {\n std: 'sourceList / destinationList',\n part: 'fields',\n label: 'twin.epcis.sourceDestination',\n axis: null,\n structure: 'partial',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.sourceDestination'\n },\n {\n std: 'persistentDisposition',\n part: 'fields',\n label: 'twin.epcis.persistentDisposition',\n axis: null,\n structure: 'partial',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.persistentDisposition'\n },\n /*\n * ── 2026-08-24 계측이 어디로 들어오는지 확정됐다 ──────────────────────────\n * 「계측은 EPCIS 센서 필드로 오지 않는다」는 사실이 이제 **자리를 갖는다**: 자리의 상시 관측은\n * ISA-95 `OperationsEvent` 로 들어오고(커널 `location.measured` 채널), 에너지는 그 위의 누적기가\n * 받는다. ISA-95 표의 `OperationsEvent` 줄이 그 판정을 든다.\n *\n * 왜 EPCIS 가 아닌가: `SensorElement` 는 **개체에 붙는** 관측이고, 냉장실의 온도는 그 안의 물건\n * 수백 개와 관계되며 그 수백 개는 시간에 따라 바뀐다. 개체마다 붙이면 같은 사실이 수백 벌이 되고,\n * 물건이 떠나면 그 방의 온도 이력이 함께 사라진다. 주인은 **자리**다.\n *\n * 그래서 이 줄은 `behavior: 'none'` 으로 남는다 — 결손이 아니라 **다른 층으로 들어온다는 사실**이다.\n * 그 구별을 표가 말하지 않으면 「센서를 못 받는 트윈」으로 읽힌다.\n */\n {\n std: 'sensorElementList',\n part: 'fields',\n label: 'twin.epcis.sensorElement',\n axis: null,\n structure: 'partial',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.sensorElement'\n }\n]\n\nexport const EPCIS_CONCEPTS: Isa95Concept[] = [...EVENTS, ...FIELDS]\n\nexport function epcisCoverage(axes: string[]): {\n model: string\n concepts: (Isa95Concept & { axisMissing?: boolean })[]\n totals: { concepts: number; structure: number; behavior: number; surface: number }\n} {\n const concepts = EPCIS_CONCEPTS.map(c => reconcileAxes(c, new Set(axes)))\n const count = (k: 'structure' | 'behavior' | 'surface') => concepts.filter(c => c[k] === 'full').length\n return {\n model: 'GS1 EPCIS 2.0',\n concepts,\n totals: { concepts: concepts.length, structure: count('structure'), behavior: count('behavior'), surface: count('surface') }\n }\n}\n"]}
1
+ {"version":3,"file":"epcis-coverage.js","sourceRoot":"","sources":["../../../server/service/twin-model/epcis-coverage.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * GS1 EPCIS 2.0 적합성 기준 — **물류·제조 트윈이 표준의 어디를 채우고 있나.**\n *\n * ── 왜 따로 있나 ────────────────────────────────────────────────────────────\n * 적합성 패널이 ISA-95 하나만 말하고 있었다. 그런데 창고·야드·공장 트윈에서 「무슨 일이 있었나」와\n * 「이 번호가 무엇을 가리키나」를 정하는 것은 EPCIS 다. ISA-95 표만 보이면 그 트윈이 기대는 기준의\n * 절반이 화면에 없다.\n *\n * ── 수준은 근거를 보고 적었다 ───────────────────────────────────────────────\n * 「있다/없다」를 기억으로 적지 않는다. 각 줄의 판단 근거:\n * · 타입 선언 — 커널 `epcis.ts`(이벤트·필드의 모양)\n * · 실제 사용 — 커널 도메인(`mes-kernel`·`face2-adapter`·`observed-reducer`)과 호스트 인제스트\n * · 화면 노출 — 저널·계보·엔티티 360\n * 모양만 있고 아무도 채우지 않는 필드는 **구조만 있음**으로 적는다(그게 사실이다).\n *\n * ISA-95 표와 같은 규율이다(`isa95-coverage.ts` 머리말): 사람이 판단해 적고, 틀리면 여기를 고친다.\n */\nimport { reconcileAxes, type Isa95Concept, type CoverageLevel } from './isa95-coverage.js'\n\n/* Part: EPCIS 2.0 의 사건 종류 — 무엇이 일어났는가. */\nconst EVENTS: Isa95Concept[] = [\n {\n std: 'ObjectEvent',\n part: 'events',\n label: 'twin.epcis.ObjectEvent',\n axis: 'items',\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.ObjectEvent'\n },\n {\n std: 'AggregationEvent',\n part: 'events',\n label: 'twin.epcis.AggregationEvent',\n axis: null,\n shownOn: ['items'],\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.AggregationEvent'\n },\n {\n std: 'TransactionEvent',\n part: 'events',\n label: 'twin.epcis.TransactionEvent',\n axis: 'orders',\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.TransactionEvent'\n },\n {\n std: 'TransformationEvent',\n part: 'events',\n label: 'twin.epcis.TransformationEvent',\n axis: null,\n shownOn: ['items', 'operations'],\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.TransformationEvent'\n },\n /* EPCIS 2.0 이 더한 사건 — 우리는 타입조차 두지 않았다. 없는 것은 없다고 적는다. */\n {\n std: 'AssociationEvent',\n part: 'events',\n label: 'twin.epcis.AssociationEvent',\n axis: null,\n structure: 'none',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.AssociationEvent'\n }\n]\n\n/* Part: 사건이 답하는 다섯 가지 — 무엇을·어디서·언제·왜, 그리고 어떤 거래로. */\nconst FIELDS: Isa95Concept[] = [\n {\n std: 'epcList / quantityList',\n part: 'fields',\n label: 'twin.epcis.identity',\n axis: 'items',\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.identity'\n },\n /*\n * ── 2026-08-24 주장 근거가 달라졌다 ────────────────────────────────────────\n * 이 줄은 오래 `full` 이었지만 근거가 **우리 선언**이었다. 이제 **정본 원문 대조**다:\n * CBV Standard Release 2.0(Ratified Jun 2022) §7.2.3 의 처분 38개를 전수 확인했다.\n *\n * 그 대조가 실제로 셋을 바꿨다.\n * · `expired` — 있는 낱말인데 우리가 쓰지 않고 있었다(기한 경과를 처분으로 말할 수 있게 됐다)\n * · `conformant` / `non_conformant` — 검사 판정을 처분으로 남기는 낱말. 함께 `inspecting` bizStep\n * · `non_sellable_expired` 는 표준이 **폐기**하고 `expired` 로 대체한 것을 확인\n *\n * ★ 그리고 **검증 방법에 함정이 있다**: `ref.gs1.org/cbv/…` 로 URN 을 조회하면 **지어낸 값에도\n * 똑같은 응답**이 온다. 그것으로 확인했다고 여기면 없는 낱말을 발행한다(한 번 그렇게 했다).\n * 확인은 정본 문서로만 한다.\n */\n {\n std: 'bizStep / disposition (CBV)',\n part: 'fields',\n label: 'twin.epcis.cbv',\n axis: null,\n shownOn: ['items', 'tasks'],\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.cbv'\n },\n {\n std: 'readPoint / bizLocation',\n part: 'fields',\n label: 'twin.epcis.where',\n axis: 'locations',\n structure: 'full',\n behavior: 'full',\n surface: 'full',\n note: 'twin.epcis.note.where'\n },\n {\n std: 'bizTransactionList',\n part: 'fields',\n label: 'twin.epcis.bizTransaction',\n axis: 'orders',\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.bizTransaction'\n },\n {\n std: 'ilmd',\n part: 'fields',\n label: 'twin.epcis.ilmd',\n axis: null,\n shownOn: ['items'],\n structure: 'full',\n behavior: 'full',\n surface: 'partial',\n note: 'twin.epcis.note.ilmd'\n },\n {\n /*\n * 2026-08-24 — **`shownOn` 을 걷었다.** 같은 줄의 `note` 가 「화면에 보이는 곳은 아직 없습니다」라고\n * 말하는데 배지는 「물품에서 표시됩니다」라고 말하고 있었다 — 한 줄이 서로 반대를 말했다.\n * `surface: 'none'` 이면 갈 곳을 적지 않는다(§`shownOn` 규칙).\n */\n std: 'errorDeclaration',\n part: 'fields',\n label: 'twin.epcis.errorDeclaration',\n axis: null,\n structure: 'full',\n behavior: 'partial',\n surface: 'none',\n note: 'twin.epcis.note.errorDeclaration'\n },\n /* 모양만 있고 아무도 채우지 않는 것들 — 구조만 있음. */\n {\n std: 'sourceList / destinationList',\n part: 'fields',\n label: 'twin.epcis.sourceDestination',\n axis: null,\n structure: 'partial',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.sourceDestination'\n },\n {\n std: 'persistentDisposition',\n part: 'fields',\n label: 'twin.epcis.persistentDisposition',\n axis: null,\n structure: 'partial',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.persistentDisposition'\n },\n /*\n * ── 2026-08-24 계측이 어디로 들어오는지 확정됐다 ──────────────────────────\n * 「계측은 EPCIS 센서 필드로 오지 않는다」는 사실이 이제 **자리를 갖는다**: 자리의 상시 관측은\n * ISA-95 `OperationsEvent` 로 들어오고(커널 `location.measured` 채널), 에너지는 그 위의 누적기가\n * 받는다. ISA-95 표의 `OperationsEvent` 줄이 그 판정을 든다.\n *\n * 왜 EPCIS 가 아닌가: `SensorElement` 는 **개체에 붙는** 관측이고, 냉장실의 온도는 그 안의 물건\n * 수백 개와 관계되며 그 수백 개는 시간에 따라 바뀐다. 개체마다 붙이면 같은 사실이 수백 벌이 되고,\n * 물건이 떠나면 그 방의 온도 이력이 함께 사라진다. 주인은 **자리**다.\n *\n * 그래서 이 줄은 `behavior: 'none'` 으로 남는다 — 결손이 아니라 **다른 층으로 들어온다는 사실**이다.\n * 그 구별을 표가 말하지 않으면 「센서를 못 받는 트윈」으로 읽힌다.\n */\n {\n std: 'sensorElementList',\n part: 'fields',\n label: 'twin.epcis.sensorElement',\n axis: null,\n structure: 'partial',\n behavior: 'none',\n surface: 'none',\n note: 'twin.epcis.note.sensorElement'\n }\n]\n\nexport const EPCIS_CONCEPTS: Isa95Concept[] = [...EVENTS, ...FIELDS]\n\nexport function epcisCoverage(axes: string[]): {\n model: string\n concepts: (Isa95Concept & { axisMissing?: boolean })[]\n totals: { concepts: number; structure: number; behavior: number; surface: number }\n} {\n const concepts = EPCIS_CONCEPTS.map(c => reconcileAxes(c, new Set(axes)))\n const count = (k: 'structure' | 'behavior' | 'surface') => concepts.filter(c => c[k] === 'full').length\n return {\n model: 'GS1 EPCIS 2.0',\n concepts,\n totals: { concepts: concepts.length, structure: count('structure'), behavior: count('behavior'), surface: count('surface') }\n }\n}\n"]}
@@ -23,6 +23,16 @@ export interface Isa95Concept {
23
23
  * 라고만 말하면 **표가 스스로 모순된 말을 한다**(다 됐다면서 없다고 한다).
24
24
  *
25
25
  * 그래서 축일 수 없는 것은 갈 곳을 가리킨다. 여기가 비어 있고 축도 없으면 그것은 **진짜 빈칸**이다.
26
+ *
27
+ * ── ★ `surface` 와 짝을 맞춘다 (2026-08-24 실측으로 붙임) ─────────────────
28
+ * 이 칸은 「그 사실이 어느 축에 **담겨 있나**」가 아니라 「**어디서 보이나**」다. 화면이 그것을
29
+ * 「해당 축 항목에 표시됩니다」로 읽어 내므로, 둘이 어긋나면 표가 거짓을 말한다.
30
+ *
31
+ * `surface: 'none'` 인데 여기가 차 있다 → **거짓**. 없는 화면으로 사람을 보낸다
32
+ * `surface` 가 있는데 여기가 비어 있다 → **과소**. 「진짜 빈칸」으로 읽힌다(위 규칙)
33
+ *
34
+ * 같은 날 셋을 그렇게 틀리게 넣었다: 새 개념 둘(`TestResult`·`OperationsEvent`)에 화면이 없는데
35
+ * 갈 곳을 적었고, `MaterialSublot` 은 물품 이름으로 보이는데 갈 곳을 비워 두었다.
26
36
  */
27
37
  shownOn?: string[];
28
38
  }
@@ -106,7 +106,7 @@ const PART2 = [
106
106
  * 이 부류(코드에 있는데 표가 `none`)는 자동으로 잡히지 않는다 — 표의 가드는 반대 방향만 본다
107
107
  * (축이 사라지면 구조를 내린다). 그래서 축을 늘릴 때 이 표를 함께 보는 것이 규율이다.
108
108
  */
109
- { std: 'MaterialSublot', part: '2', label: 'twin.isa95.MaterialSublot', axis: null, structure: 'partial', behavior: 'full', surface: 'partial' },
109
+ { std: 'MaterialSublot', part: '2', label: 'twin.isa95.MaterialSublot', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'full', surface: 'partial' },
110
110
  { std: 'ProcessSegment', part: '2', label: 'twin.isa95.ProcessSegment', axis: 'operations', structure: 'full', behavior: 'full', surface: 'full' },
111
111
  /*
112
112
  * 속성은 자원마다 붙는 확장이라 **자기 축이 아니다** — 그래서 `axis` 는 없지만 구조는 완전하다:
@@ -139,7 +139,7 @@ const PART2 = [
139
139
  * `observation-out-of-limit` 신호를 세운다.
140
140
  * 화면이 `none`: 클라이언트에 이 축을 그리는 곳이 없다(실측 0곳).
141
141
  */
142
- { std: 'OperationsEvent', part: '2', label: 'twin.isa95.OperationsEvent', axis: null, shownOn: ['locations'], structure: 'partial', behavior: 'full', surface: 'none' }
142
+ { std: 'OperationsEvent', part: '2', label: 'twin.isa95.OperationsEvent', axis: null, structure: 'partial', behavior: 'full', surface: 'none' }
143
143
  ];
144
144
  /*
145
145
  * Part 4 — 일정과 실적.
@@ -203,7 +203,7 @@ const PART4 = [
203
203
  *
204
204
  * 화면은 `none` 이다 — 클라이언트에 이 축을 그리는 곳이 없다(실측: `operato-twin/client` 에 0곳).
205
205
  */
206
- { std: 'TestResult', part: '4', label: 'twin.isa95.TestResult', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'partial', surface: 'none' },
206
+ { std: 'TestResult', part: '4', label: 'twin.isa95.TestResult', axis: null, structure: 'partial', behavior: 'partial', surface: 'none' },
207
207
  { std: 'WorkMaster', part: '4', label: 'twin.isa95.WorkMaster', axis: 'recipes', structure: 'full', behavior: 'full', surface: 'full' },
208
208
  { std: 'WorkDirective', part: '4', label: 'twin.isa95.WorkDirective', axis: null, structure: 'none', behavior: 'none', surface: 'none' }
209
209
  ];