@things-factory/headless-twin 10.0.18 → 10.0.19
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.
- package/dist-server/routes.js +12 -1
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
- package/dist-server/service/actuation/command-dispatcher.js +92 -0
- package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
- package/dist-server/service/actuation/command-store.d.ts +3 -0
- package/dist-server/service/actuation/command-store.js +56 -0
- package/dist-server/service/actuation/command-store.js.map +1 -0
- package/dist-server/service/actuation/index.d.ts +6 -0
- package/dist-server/service/actuation/index.js +11 -0
- package/dist-server/service/actuation/index.js.map +1 -0
- package/dist-server/service/actuation/twin-command.d.ts +30 -0
- package/dist-server/service/actuation/twin-command.js +110 -0
- package/dist-server/service/actuation/twin-command.js.map +1 -0
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/index.js +20 -17
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/hook-contract.d.ts +64 -21
- package/dist-server/service/reference/hook-contract.js +134 -22
- package/dist-server/service/reference/hook-contract.js.map +1 -1
- package/dist-server/service/reference/hook-store.d.ts +3 -0
- package/dist-server/service/reference/hook-store.js +28 -0
- package/dist-server/service/reference/hook-store.js.map +1 -0
- package/dist-server/service/reference/reference-adapter.d.ts +65 -1
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-hook.d.ts +42 -6
- package/dist-server/service/reference/reference-hook.js +185 -27
- package/dist-server/service/reference/reference-hook.js.map +1 -1
- package/package.json +7 -7
- package/server/routes.ts +12 -1
- package/server/service/actuation/command-dispatcher.ts +130 -0
- package/server/service/actuation/command-store.ts +62 -0
- package/server/service/actuation/index.ts +8 -0
- package/server/service/actuation/twin-command.ts +105 -0
- package/server/service/index.ts +3 -0
- package/server/service/reference/hook-contract.ts +99 -21
- package/server/service/reference/hook-store.ts +28 -0
- package/server/service/reference/reference-adapter.ts +67 -2
- package/server/service/reference/reference-hook.ts +249 -32
- package/test/actuation-dispatch.test.ts +196 -0
- package/test/hook-sequence.test.ts +333 -0
- package/test/reference-hook.test.ts +114 -23
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reference-adapter.js","sourceRoot":"","sources":["../../../server/service/reference/reference-adapter.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ReferenceStep } from './reference-progress.js'\nimport type { ReferenceMaster } from './reference-master.js'\nimport { twinWarn } from '../../engine/log.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 * 값이 정해진 목록 안에 있을 때 — 화면이 고르게 그린다(타이핑하지 않는다).\n *\n * 손으로 적는 칸에 닫힌 값을 받으면 오타가 그대로 저장되고, 그 오타는 오류로 나오지 않는다.\n * 발전소 종류(`sell` | `self-consumption`)가 그렇다 — 틀리면 계량 방향이 반대가 되는데 어디에도\n * 표시되지 않는다.\n *\n * `label` 은 번역 키를 쓸 수 있다(화면이 `meta()` 로 푼다). `value` 는 저장되는 값이라 번역하지 않는다.\n */\n options?: { value: string; label: string }[]\n /*\n * 값의 종류가 정해져 있지만 목록이 너무 커서 `options` 로 실을 수 없을 때 — 화면이 그 종류에 맞는\n * 고르는 칸을 그린다.\n *\n * 'timezone' IANA 시간대. 화면이 `Intl.supportedValuesOf('timeZone')` 로 목록을 만든다.\n * 400개가 넘고 표준이 갱신되므로 우리가 목록을 들고 있지 않는다.\n * 이름이 곧 값이자 표시라 번역하지 않는다.\n *\n * 자유 입력으로 두면 `Asia/seoul` 같은 오타가 저장되고, 그 오타는 오류로 나오지 않는다.\n */\n kind?: 'timezone'\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 * **트윈이 지금 들고 있는 물품 하나** — 붙는 커넥터가 견줄 근거(§`LiveFeedContinuity.observedItems`).\n *\n * 커널 어휘로 낸다. 원본의 낱말(그 시스템의 상태 코드)로 내지 않는 이유가 있다: 원본 낱말은 여러 개가\n * 커널의 한 값으로 모이므로(예: 네 상태가 「진행 중」 하나가 된다) 되돌릴 수 없다. 그리고 되돌릴\n * 필요도 없다 — 두 원본 상태가 커널에서 같은 값이면 **트윈이 든 사실은 바뀌지 않았다.**\n */\nexport interface ObservedItemFact {\n /** 트윈이 든 물품의 키 — 비직렬 재고는 `클래스@자리`. */\n id: string\n /** 클래스 식별자(원문). */\n epcClass?: string\n location?: string\n qty?: number\n uom?: string\n /** 표준 처분(CBV) — 원본 상태 코드가 아니다. */\n disposition?: string\n /** 유통기한(밀리초). */\n expiry?: number\n lot?: string\n}\n\n/**\n * 재기동을 넘어 이어지는 읽기 상태 — 참조 계층이 소유하고 어댑터에 건넨다(§`openLiveFeed`).\n *\n * 흐름 열쇠는 **어댑터가 정한다**(`Record`). 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다.\n */\nexport interface LiveFeedContinuity {\n /** 지난번에 어디까지 읽었나 — 없으면 첫 붙음이다(그때만 되돌아볼 날수로 창을 만든다). */\n cursor?: { streams?: Record<string, LiveFeedCursor>; firstAttachedAt?: string }\n /**\n * **트윈이 지금 들고 있는 물품** — 붙는 커넥터가 「무엇이 바뀌었나」를 견줄 근거 (2026-08-28).\n *\n * ── 무엇을 고치나 ───────────────────────────────────────────────────────────\n * 전량 절대값을 읽는 커넥터는 지난 주기와 견주어 **바뀐 것만** 낸다. 그 비교 표가 프로세스 안에만\n * 있어서, 재기동하면 커넥터가 자기 기억을 잃고 **전량을 「바뀐 것」으로 판정**한다. 승화푸드에서 그\n * 한 번이 2,665건이었고, 재기동마다 지난 기록을 덮었다.\n *\n * **트윈은 잃지 않는다** — 저장본에서 되세우고 뜬다(실측 로그: `items 2665 · orders 4794`). 잃는\n * 쪽은 커넥터다. 그래서 트윈이 아는 것을 건네면 첫 주기부터 바뀐 것만 나간다.\n *\n * 없으면 `undefined` 다 — 그것이 「트윈이 아무것도 모른다」는 사실이고, 그때만 전량이 새 사실이다.\n * 빈 배열로 메우지 않는다(그러면 「모른다」와 「없다」가 같아진다).\n */\n observedItems?: ObservedItemFact[]\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 * ── 무엇을 어댑터가 보내고 무엇을 이 층이 세나 (2026-08-27) ─────────────────\n * 어댑터는 **이유**만 보낸다 — 그리고 물러섰다면 다음에 다시 물을 때까지의 시간을 함께 보낸다.\n *\n * 연달아 몇 번 실패했는지와 언제부터 실패하고 있는지는 **이 층이 이미 센다**(`recordReadFailure`).\n * 그것을 어댑터가 함께 보내면 같은 수를 두 곳에서 세게 되고, 갈라지면 어느 쪽이 사실인지 알 수 없다.\n *\n * `reason` 에 **완성된 문장을 담지 않는다.** 이 값은 화면에 그려지고, 화면은 다섯 언어로 그린다.\n * 「3회 연달아 …부터 읽지 못했습니다」처럼 한 언어의 문장을 넣으면 그 문장은 번역되지 않고, 이 층이\n * 세는 수와 어긋날 수도 있다. 이유 하나만 짧게 준다(접속 시간 초과 · 401 · 형식 오류).\n */\n onReadFailure?: (info: {\n reason: string\n stream?: string\n /** 다음에 다시 물을 때까지 남은 시간(ms) — 물러섰을 때만 준다. */\n nextRetryMs?: number\n }) => void\n /**\n * **끊겼다가 돌아왔다** — 어댑터가 회복을 알린다 (2026-08-27).\n *\n * ── 왜 따로 알려야 하나 ─────────────────────────────────────────────────────\n * 읽기가 다시 성공하면 실패 기록은 지워진다. 그것만 하면 「끊긴 적이 있었다」가 화면에서 사라지고,\n * 밤새 두 시간 끊겼던 연결과 한 번도 끊기지 않은 연결이 아침에 똑같이 보인다.\n *\n * 읽기 성공(`onRecords`)만으로 이 층이 회복을 유추하지 않는다 — 몇 번 실패한 뒤였는지와 얼마나\n * 끊겨 있었는지는 재시도를 관리하는 어댑터가 안다.\n */\n onRecovered?: (info: { afterFailures: number; downMs: number }) => 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 /**\n * (선택) **연결된 시스템이 밀어 준 것**을 우리 레코드로 옮긴다 — 웹훅.\n *\n * ── 왜 `openLiveFeed` 와 나누나 (2026-08-26) ───────────────────────────────\n * `openLiveFeed` 는 「가져오기」와 「옮기기」를 한 함수에 담고 있다. 그래서 가져오는 방식이 바뀌면\n * (그쪽이 밀어 주면) 옮기는 코드를 쓸 수 없었다 — 같은 파일 안에 있는데 밖에서 부를 수 없다.\n *\n * 이 함수는 **옮기기만** 한다. 밖에서 받은 본문을 받아 우리 레코드 목록을 돌려준다. 입구·인증·유입은\n * 프레임워크가 맡는다(§`reference-hook`).\n *\n * ── 옮길 수 없으면 던진다 ─────────────────────────────────────────────────\n * 빈 목록과 「옮길 수 없다」는 다르다. 빈 목록은 「우리와 무관한 것이 왔다」이고, 던지는 것은 「그쪽\n * 모양이 우리가 아는 것과 다르다」다. 그 둘을 같게 답하면 그쪽 모양이 바뀐 것을 아무도 모른다.\n *\n * ── 판단은 여기서 한다 ────────────────────────────────────────────────────\n * 통신이 끊긴 설비의 값을 보내지 않는 것, 잰 시각이 나아가지 않은 줄을 거르는 것 — 폴링에서 하던\n * 판단을 여기서도 한다. 그래야 두 길이 같은 답을 낸다.\n */\n handleInbound?(cfg: ConnectionConfig, site: { siteId: string }, body: unknown, headers: Record<string, unknown>): unknown[]\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\n/**\n * 어댑터를 등록한다 — 그리고 **선언과 구현이 어긋나면 그 자리에서 말한다.**\n *\n * ── 왜 필요한가 (2026-08-26 실측) ──────────────────────────────────────────\n * 능력 판정(`capabilitiesOf`)은 **한 방향만** 본다: 선언했는데 구현이 없는 것은 걸러 내고, **구현했는데\n * 선언이 없는 것은 아무 말도 하지 않는다.** 그래서 다음이 오류 없이 일어난다.\n *\n * 커넥터가 `openLiveFeed` 를 구현한다 → `capabilities: ['live']` 를 잊는다\n * → 연결 화면이 「이 원본은 라이브를 못 낸다」로 읽는다\n * → 재기동 정책을 정할 수 없어 트윈 생성이 거절된다\n * → 사람이 보는 것은 「재기동 정책이 없습니다」뿐이다\n *\n * 실제로 그렇게 막혔고(태양광 발전소), 원인을 찾는 데 코드를 뒤져야 했다. 지금 저장소에서 같은 상태인\n * 커넥터가 일곱이다.\n *\n * ── 왜 막지 않고 알리기만 하나 ──────────────────────────────────────────────\n * 등록에서 던지면 커넥터 하나 때문에 호스트가 못 뜬다. 만들다 만 커넥터를 들고 있는 사람이 아무것도\n * 못 하게 된다. 그리고 이 어긋남은 **고칠 수 있는 것**이지 위험한 것이 아니다 — 말해 주면 된다.\n */\nexport function registerAdapterType(adapter: ReferenceAdapter): void {\n const declared = new Set(adapter.capabilities ?? [])\n const undeclared = (['live', 'control'] as const).filter(\n c => typeof (adapter as any)[c === 'live' ? 'openLiveFeed' : 'control'] === 'function' && !declared.has(c)\n )\n if (undeclared.length) {\n twinWarn(\n `[reference] connector \"${adapter.type}\" implements ${undeclared.join(' and ')} but does not declare it — ` +\n `add capabilities: [${undeclared.map(c => `'${c}'`).join(', ')}] or remove the implementation. ` +\n 'Until then the screens treat this connector as unable to do it, and a live twin cannot be created from it.'\n )\n }\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":"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","sourcesContent":["import type { ApprovedCommand } from '@operato/ops-contract'\n\nimport type { ReferenceStep } from './reference-progress.js'\nimport type { ReferenceMaster } from './reference-master.js'\nimport { twinWarn } from '../../engine/log.js'\n\n/*\n * 레퍼런스 어댑터 계약 (reference-management P1) — 정적 마스터 등록(`registerReference`)을\n * \"어댑터 타입\" 등록으로 일반화한다. 레퍼런스 = 데이터(TwinReference 행), 어댑터 = 동작.\n * 아웃바운드 액추에이션은 **별개 어댑터가 아니라 이 어댑터의 반대 면**이다(`actuate`) — 같은 연결이\n * 사실을 읽고 조치를 내리므로 인증과 설정이 한 자리에 있어야 한다(command-routing §8.1).\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 * 값이 정해진 목록 안에 있을 때 — 화면이 고르게 그린다(타이핑하지 않는다).\n *\n * 손으로 적는 칸에 닫힌 값을 받으면 오타가 그대로 저장되고, 그 오타는 오류로 나오지 않는다.\n * 발전소 종류(`sell` | `self-consumption`)가 그렇다 — 틀리면 계량 방향이 반대가 되는데 어디에도\n * 표시되지 않는다.\n *\n * `label` 은 번역 키를 쓸 수 있다(화면이 `meta()` 로 푼다). `value` 는 저장되는 값이라 번역하지 않는다.\n */\n options?: { value: string; label: string }[]\n /*\n * 값의 종류가 정해져 있지만 목록이 너무 커서 `options` 로 실을 수 없을 때 — 화면이 그 종류에 맞는\n * 고르는 칸을 그린다.\n *\n * 'timezone' IANA 시간대. 화면이 `Intl.supportedValuesOf('timeZone')` 로 목록을 만든다.\n * 400개가 넘고 표준이 갱신되므로 우리가 목록을 들고 있지 않는다.\n * 이름이 곧 값이자 표시라 번역하지 않는다.\n *\n * 자유 입력으로 두면 `Asia/seoul` 같은 오타가 저장되고, 그 오타는 오류로 나오지 않는다.\n */\n kind?: 'timezone'\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 * **트윈이 지금 들고 있는 물품 하나** — 붙는 커넥터가 견줄 근거(§`LiveFeedContinuity.observedItems`).\n *\n * 커널 어휘로 낸다. 원본의 낱말(그 시스템의 상태 코드)로 내지 않는 이유가 있다: 원본 낱말은 여러 개가\n * 커널의 한 값으로 모이므로(예: 네 상태가 「진행 중」 하나가 된다) 되돌릴 수 없다. 그리고 되돌릴\n * 필요도 없다 — 두 원본 상태가 커널에서 같은 값이면 **트윈이 든 사실은 바뀌지 않았다.**\n */\nexport interface ObservedItemFact {\n /** 트윈이 든 물품의 키 — 비직렬 재고는 `클래스@자리`. */\n id: string\n /** 클래스 식별자(원문). */\n epcClass?: string\n location?: string\n qty?: number\n uom?: string\n /** 표준 처분(CBV) — 원본 상태 코드가 아니다. */\n disposition?: string\n /** 유통기한(밀리초). */\n expiry?: number\n lot?: string\n}\n\n/**\n * 밀어 받은 본문 하나를 옮긴 결과 (2026-08-31).\n *\n * 레코드만 돌려주던 것을 넓혔다. 번호를 함께 받아야 프레임워크가 빠진 것을 알아챈다.\n *\n * **`scope` 가 없으면 `seq` 도 없는 것으로 다룬다.** 번호만 있고 그 번호가 어느 줄의 것인지 모르면\n * 커서를 무엇으로 잡을지 짐작해야 하고, 짐작한 열쇠는 보내는 쪽이 줄을 하나 더 늘리는 날 어긋난다.\n * 실제로 operato-mes 가 `[domain, channel]` 마다 번호를 매기면서 봉투에 `channel` 을 싣지 않고 있었다.\n */\nexport interface InboundBatch {\n /**\n * 옮긴 것들. 우리와 무관한 본문이면 빈 배열이다.\n *\n * **한 요청에 봉투가 여럿 온다.** 밀어 주는 쪽은 한 건씩 보내지 않는다 — 번호도 봉투마다 붙는다.\n * 배치를 대표하는 번호 하나로는 가운데가 빠진 것을 알아채지 못한다.\n */\n items: InboundItem[]\n /**\n * 이 요청의 번호가 어느 줄의 것인가. 번호를 매기지 않는 원본은 주지 않는다.\n *\n * 요청 하나가 한 줄에만 속한다 — 한 요청에 두 줄을 섞으면 어느 줄의 어디까지 받았는지를 응답\n * 하나로 말할 수 없다.\n */\n scope?: string\n}\n\nexport interface InboundItem {\n /** 우리 어휘로 옮긴 레코드 하나. */\n record: unknown\n /**\n * 이 봉투의 번호.\n *\n * **한 배치 안에서 있거나 없거나 하나로 통일한다.** 섞이면 빠진 것이 있는지 판단할 근거가 없다.\n */\n seq?: number\n}\n\n/**\n * 재기동을 넘어 이어지는 읽기 상태 — 참조 계층이 소유하고 어댑터에 건넨다(§`openLiveFeed`).\n *\n * 흐름 열쇠는 **어댑터가 정한다**(`Record`). 계약이 이름을 닫으면 새 원본마다 계약을 고치게 된다.\n */\nexport interface LiveFeedContinuity {\n /** 지난번에 어디까지 읽었나 — 없으면 첫 붙음이다(그때만 되돌아볼 날수로 창을 만든다). */\n cursor?: { streams?: Record<string, LiveFeedCursor>; firstAttachedAt?: string }\n /**\n * **트윈이 지금 들고 있는 물품** — 붙는 커넥터가 「무엇이 바뀌었나」를 견줄 근거 (2026-08-28).\n *\n * ── 무엇을 고치나 ───────────────────────────────────────────────────────────\n * 전량 절대값을 읽는 커넥터는 지난 주기와 견주어 **바뀐 것만** 낸다. 그 비교 표가 프로세스 안에만\n * 있어서, 재기동하면 커넥터가 자기 기억을 잃고 **전량을 「바뀐 것」으로 판정**한다. 승화푸드에서 그\n * 한 번이 2,665건이었고, 재기동마다 지난 기록을 덮었다.\n *\n * **트윈은 잃지 않는다** — 저장본에서 되세우고 뜬다(실측 로그: `items 2665 · orders 4794`). 잃는\n * 쪽은 커넥터다. 그래서 트윈이 아는 것을 건네면 첫 주기부터 바뀐 것만 나간다.\n *\n * 없으면 `undefined` 다 — 그것이 「트윈이 아무것도 모른다」는 사실이고, 그때만 전량이 새 사실이다.\n * 빈 배열로 메우지 않는다(그러면 「모른다」와 「없다」가 같아진다).\n */\n observedItems?: ObservedItemFact[]\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 * ── 무엇을 어댑터가 보내고 무엇을 이 층이 세나 (2026-08-27) ─────────────────\n * 어댑터는 **이유**만 보낸다 — 그리고 물러섰다면 다음에 다시 물을 때까지의 시간을 함께 보낸다.\n *\n * 연달아 몇 번 실패했는지와 언제부터 실패하고 있는지는 **이 층이 이미 센다**(`recordReadFailure`).\n * 그것을 어댑터가 함께 보내면 같은 수를 두 곳에서 세게 되고, 갈라지면 어느 쪽이 사실인지 알 수 없다.\n *\n * `reason` 에 **완성된 문장을 담지 않는다.** 이 값은 화면에 그려지고, 화면은 다섯 언어로 그린다.\n * 「3회 연달아 …부터 읽지 못했습니다」처럼 한 언어의 문장을 넣으면 그 문장은 번역되지 않고, 이 층이\n * 세는 수와 어긋날 수도 있다. 이유 하나만 짧게 준다(접속 시간 초과 · 401 · 형식 오류).\n */\n onReadFailure?: (info: {\n reason: string\n stream?: string\n /** 다음에 다시 물을 때까지 남은 시간(ms) — 물러섰을 때만 준다. */\n nextRetryMs?: number\n }) => void\n /**\n * **끊겼다가 돌아왔다** — 어댑터가 회복을 알린다 (2026-08-27).\n *\n * ── 왜 따로 알려야 하나 ─────────────────────────────────────────────────────\n * 읽기가 다시 성공하면 실패 기록은 지워진다. 그것만 하면 「끊긴 적이 있었다」가 화면에서 사라지고,\n * 밤새 두 시간 끊겼던 연결과 한 번도 끊기지 않은 연결이 아침에 똑같이 보인다.\n *\n * 읽기 성공(`onRecords`)만으로 이 층이 회복을 유추하지 않는다 — 몇 번 실패한 뒤였는지와 얼마나\n * 끊겨 있었는지는 재시도를 관리하는 어댑터가 안다.\n */\n onRecovered?: (info: { afterFailures: number; downMs: number }) => 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 /**\n * (선택) **연결된 시스템이 밀어 준 것**을 우리 레코드로 옮긴다 — 웹훅.\n *\n * ── 왜 `openLiveFeed` 와 나누나 (2026-08-26) ───────────────────────────────\n * `openLiveFeed` 는 「가져오기」와 「옮기기」를 한 함수에 담고 있다. 그래서 가져오는 방식이 바뀌면\n * (그쪽이 밀어 주면) 옮기는 코드를 쓸 수 없었다 — 같은 파일 안에 있는데 밖에서 부를 수 없다.\n *\n * 이 함수는 **옮기기만** 한다. 밖에서 받은 본문을 받아 우리 레코드 목록을 돌려준다. 입구·인증·유입은\n * 프레임워크가 맡는다(§`reference-hook`).\n *\n * ── 옮길 수 없으면 던진다 ─────────────────────────────────────────────────\n * 빈 목록과 「옮길 수 없다」는 다르다. 빈 목록은 「우리와 무관한 것이 왔다」이고, 던지는 것은 「그쪽\n * 모양이 우리가 아는 것과 다르다」다. 그 둘을 같게 답하면 그쪽 모양이 바뀐 것을 아무도 모른다.\n *\n * ── 판단은 여기서 한다 ────────────────────────────────────────────────────\n * 통신이 끊긴 설비의 값을 보내지 않는 것, 잰 시각이 나아가지 않은 줄을 거르는 것 — 폴링에서 하던\n * 판단을 여기서도 한다. 그래야 두 길이 같은 답을 낸다.\n *\n * ── 번호는 프레임워크가 본다 (2026-08-31) ─────────────────────────────────\n * 빠진 번호를 알아채는 일은 밀어 주는 모든 연결에 같은 규율이라야 한다. 커넥터마다 만들면 한 곳이\n * 빠지고, 빠진 그 연결에서만 사실이 없어진다. 그래서 커넥터는 **번호를 꺼내 주기만** 하고 판정은\n * 프레임워크가 한다(§`reference-hook`).\n */\n handleInbound?(cfg: ConnectionConfig, site: { siteId: string }, body: unknown, headers: Record<string, unknown>): InboundBatch\n\n /**\n * **현장에 조치를 내린다** — 인바운드의 반대 면(§`command-routing.md` §8.1).\n *\n * 별개 어댑터를 두지 않는다. 같은 연결이 사실을 읽고 조치를 내리므로, 인증과 설정이 한 자리에 있어야\n * 한다. 둘로 나누면 같은 시스템에 두 벌의 연결 설정이 생긴다.\n *\n * ── 승인은 이미 지났다 (2026-08-31) ───────────────────────────────────────\n * 인자가 `ApprovedCommand` 다 — 그 표식은 승인을 지난 커맨드에만 붙고, 디스패처가 붙인다\n * (§`command-dispatcher.ts`). **어댑터는 승인을 다시 확인하지 않는다.** 확인이 두 곳에 있으면\n * 규칙이 두 벌이 되고 한쪽만 고쳐지는 날이 온다.\n *\n * 이 자리를 게이트보다 먼저 열지 않은 이유가 있다 — 자리가 있으면 누군가 부르고, 그때 사람 승인 없이\n * 현장에 작업지시가 나간다. 작업지시는 취소해도 이미 만든 것이 남는다.\n *\n * ── 돌려줄 것 ─────────────────────────────────────────────────────────────\n * `ref` 를 **반드시** 돌려준다. 그것이 없으면 넘긴 것이 저쪽에서 무엇이 되었는지 되짚을 수 없고,\n * 되돌릴 때 무엇을 되돌릴지 말할 수 없다.\n */\n actuate?(cfg: ConnectionConfig, site: SiteDescriptor, command: ApprovedCommand): Promise<{ ok: boolean; ref?: string; error?: string }>\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\n/**\n * 어댑터를 등록한다 — 그리고 **선언과 구현이 어긋나면 그 자리에서 말한다.**\n *\n * ── 왜 필요한가 (2026-08-26 실측) ──────────────────────────────────────────\n * 능력 판정(`capabilitiesOf`)은 **한 방향만** 본다: 선언했는데 구현이 없는 것은 걸러 내고, **구현했는데\n * 선언이 없는 것은 아무 말도 하지 않는다.** 그래서 다음이 오류 없이 일어난다.\n *\n * 커넥터가 `openLiveFeed` 를 구현한다 → `capabilities: ['live']` 를 잊는다\n * → 연결 화면이 「이 원본은 라이브를 못 낸다」로 읽는다\n * → 재기동 정책을 정할 수 없어 트윈 생성이 거절된다\n * → 사람이 보는 것은 「재기동 정책이 없습니다」뿐이다\n *\n * 실제로 그렇게 막혔고(태양광 발전소), 원인을 찾는 데 코드를 뒤져야 했다. 지금 저장소에서 같은 상태인\n * 커넥터가 일곱이다.\n *\n * ── 왜 막지 않고 알리기만 하나 ──────────────────────────────────────────────\n * 등록에서 던지면 커넥터 하나 때문에 호스트가 못 뜬다. 만들다 만 커넥터를 들고 있는 사람이 아무것도\n * 못 하게 된다. 그리고 이 어긋남은 **고칠 수 있는 것**이지 위험한 것이 아니다 — 말해 주면 된다.\n */\nexport function registerAdapterType(adapter: ReferenceAdapter): void {\n const declared = new Set(adapter.capabilities ?? [])\n const undeclared = (['live', 'control'] as const).filter(\n c => typeof (adapter as any)[c === 'live' ? 'openLiveFeed' : 'control'] === 'function' && !declared.has(c)\n )\n if (undeclared.length) {\n twinWarn(\n `[reference] connector \"${adapter.type}\" implements ${undeclared.join(' and ')} but does not declare it — ` +\n `add capabilities: [${undeclared.map(c => `'${c}'`).join(', ')}] or remove the implementation. ` +\n 'Until then the screens treat this connector as unable to do it, and a live twin cannot be created from it.'\n )\n }\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"]}
|
|
@@ -11,10 +11,46 @@ export declare function handleHook(args: {
|
|
|
11
11
|
instanceId: string;
|
|
12
12
|
headers: Record<string, unknown>;
|
|
13
13
|
body: unknown;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
/**
|
|
15
|
+
* 받은 바이트 그대로 — 서명을 확인하려면 이것이 있어야 한다.
|
|
16
|
+
*
|
|
17
|
+
* 파싱한 객체를 다시 문자열로 만들면 키 순서와 공백이 달라져 서명이 맞지 않는다. 없으면 서명을 쓰는
|
|
18
|
+
* 요청은 거절된다(§`authorizeHook`).
|
|
19
|
+
*/
|
|
20
|
+
rawBody?: string;
|
|
21
|
+
/** 서명 창을 재는 기준 시각. 부르는 쪽이 준다 — 여기서 읽으면 시험이 시계에 매인다. */
|
|
22
|
+
nowMs?: number;
|
|
23
|
+
/** 레코드를 트윈에 넣는다 — 받은 수와 **떨어진 것들**을 돌려준다. */
|
|
24
|
+
ingest: (instanceId: string, records: unknown[]) => IngestResult;
|
|
25
|
+
/**
|
|
26
|
+
* 저장소를 대신 쓰는 자리 — **시험이 재기동을 넣을 수 있게.**
|
|
27
|
+
*
|
|
28
|
+
* `ingest` 를 부르는 쪽이 넘겨 주는 것과 같은 이유다(위 §). 커서는 재기동을 넘어 살아야 하는데, 한
|
|
29
|
+
* 프로세스 안에서만 확인하면 「적히나」는 보이지만 「다시 세우면 읽히나」는 안 보인다. 그 둘이 갈린
|
|
30
|
+
* 결함을 이 저장소가 이미 겪었다.
|
|
31
|
+
*/
|
|
32
|
+
store: HookStore;
|
|
20
33
|
}): Promise<HookOutcome>;
|
|
34
|
+
/**
|
|
35
|
+
* 유입이 돌려주는 것.
|
|
36
|
+
*
|
|
37
|
+
* 떨어진 것을 **수가 아니라 목록으로** 받는다. 수만 받으면 보내는 쪽에 「몇 건이 떨어졌다」밖에 말할 수
|
|
38
|
+
* 없고, 그러면 무엇을 따로 두어야 할지 알 수 없어 배치 전체를 다시 보내거나 전부 버린다.
|
|
39
|
+
*/
|
|
40
|
+
export interface IngestResult {
|
|
41
|
+
applied: number;
|
|
42
|
+
duplicates: number;
|
|
43
|
+
rejected: {
|
|
44
|
+
record: unknown;
|
|
45
|
+
errors: string[];
|
|
46
|
+
}[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것.
|
|
50
|
+
*
|
|
51
|
+
* 이 둘만 밖으로 낸다. 나머지는 인자만 보고 답하므로 시험이 데이터베이스를 세우지 않아도 된다.
|
|
52
|
+
*/
|
|
53
|
+
export interface HookStore {
|
|
54
|
+
load(domainId: string, source: string): Promise<any | null>;
|
|
55
|
+
saveCursor(refId: string, cursor: unknown): Promise<void>;
|
|
56
|
+
}
|
|
@@ -45,8 +45,8 @@ exports.handleHook = handleHook;
|
|
|
45
45
|
* 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지
|
|
46
46
|
* 함께 알립니다.
|
|
47
47
|
*/
|
|
48
|
-
const
|
|
49
|
-
const
|
|
48
|
+
const ops_contract_1 = require("@operato/ops-contract");
|
|
49
|
+
const log_js_1 = require("../../engine/log.js");
|
|
50
50
|
const reference_adapter_js_1 = require("./reference-adapter.js");
|
|
51
51
|
const hook_contract_js_1 = require("./hook-contract.js");
|
|
52
52
|
/**
|
|
@@ -56,78 +56,236 @@ const hook_contract_js_1 = require("./hook-contract.js");
|
|
|
56
56
|
* 그러면 규약을 확인하는 시험을 쓸 수 없다.
|
|
57
57
|
*/
|
|
58
58
|
async function handleHook(args) {
|
|
59
|
-
const { domainId, source, instanceId, headers, body, ingest } = args;
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
const { domainId, source, instanceId, headers, body, rawBody, ingest } = args;
|
|
60
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
61
|
+
const { store } = args;
|
|
62
|
+
const ref = await store.load(domainId, source);
|
|
63
63
|
if (!ref) {
|
|
64
|
-
return { status:
|
|
64
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.unknownTarget, body: { ok: false, error: `unknown connection "${source}"` } };
|
|
65
65
|
}
|
|
66
66
|
const cfg = ref.connectionConfig ?? {};
|
|
67
67
|
const expected = String(cfg.hookSecret ?? '').trim();
|
|
68
68
|
if (!expected) {
|
|
69
69
|
/* 비밀값을 선언하지 않은 연결은 훅을 받지 않는다 — 기본이 거부다. */
|
|
70
70
|
return {
|
|
71
|
-
status:
|
|
71
|
+
status: ops_contract_1.WEBHOOK_STATUS.badSecret,
|
|
72
72
|
body: {
|
|
73
73
|
ok: false,
|
|
74
74
|
error: `connection "${source}" declares no hookSecret — set one in the connection settings before pushing to it`
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
const auth = (0, hook_contract_js_1.authorizeHook)({ headers, rawBody, secret: expected, nowMs });
|
|
79
|
+
if (auth.ok === false) {
|
|
80
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.badSecret, body: { ok: false, error: auth.reason } };
|
|
80
81
|
}
|
|
81
82
|
const site = (0, hook_contract_js_1.producedInstance)(ref, instanceId);
|
|
82
83
|
if (!site) {
|
|
83
84
|
return {
|
|
84
|
-
status:
|
|
85
|
+
status: ops_contract_1.WEBHOOK_STATUS.unknownTarget,
|
|
85
86
|
body: { ok: false, error: `twin "${instanceId}" was not created by connection "${source}"` }
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
const adapter = (0, reference_adapter_js_1.getAdapter)(String(ref.adapterType ?? ''));
|
|
89
90
|
if (typeof adapter?.handleInbound !== 'function') {
|
|
90
91
|
return {
|
|
91
|
-
status:
|
|
92
|
+
status: ops_contract_1.WEBHOOK_STATUS.unsupported,
|
|
92
93
|
body: { ok: false, error: `connector "${ref.adapterType}" cannot take pushed events (no handleInbound)` }
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
|
-
let
|
|
96
|
+
let batch;
|
|
96
97
|
try {
|
|
97
|
-
|
|
98
|
+
batch = adapter.handleInbound(cfg, site, body, headers);
|
|
98
99
|
}
|
|
99
100
|
catch (e) {
|
|
100
101
|
/* 옮기지 못한 것은 그쪽 모양이 바뀐 것이다 — 다시 보내도 같으므로 재시도를 부르지 않는다. */
|
|
101
|
-
return { status:
|
|
102
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'cannot translate the payload' } };
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
const items = batch?.items;
|
|
105
|
+
if (!Array.isArray(items)) {
|
|
106
|
+
return {
|
|
107
|
+
status: ops_contract_1.WEBHOOK_STATUS.badPayload,
|
|
108
|
+
body: { ok: false, error: 'the connector did not return a list of items' }
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/*
|
|
112
|
+
* **번호 확인** — 이 자리가 프레임워크인 이유는 밀어 주는 모든 연결에 같은 규율이라야 하기 때문이다.
|
|
113
|
+
* 커넥터마다 만들면 한 곳이 빠지고, 빠진 그 연결에서만 사실이 없어진다.
|
|
114
|
+
*
|
|
115
|
+
* 번호를 매기지 않는 원본(chef · ppms)은 `seq` 를 주지 않고, 그때는 확인하지 않는다. 확인하지 않았다는
|
|
116
|
+
* 것은 응답에 남긴다 — 「확인했고 이상 없다」와 구별되어야 한다.
|
|
117
|
+
*/
|
|
118
|
+
const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined;
|
|
119
|
+
const withSeqCount = items.filter(i => typeof i?.seq === 'number').length;
|
|
120
|
+
if (withSeqCount && withSeqCount !== items.length) {
|
|
121
|
+
/* 섞이면 빠진 것이 있는지 판단할 근거가 없다. 반쯤 확인한 것을 확인했다고 말하지 않는다. */
|
|
122
|
+
return {
|
|
123
|
+
status: ops_contract_1.WEBHOOK_STATUS.badPayload,
|
|
124
|
+
body: { ok: false, error: `mixed batch — ${withSeqCount} of ${items.length} items carry a sequence number` }
|
|
125
|
+
};
|
|
105
126
|
}
|
|
127
|
+
const numbered = withSeqCount > 0 && scope !== undefined;
|
|
128
|
+
/*
|
|
129
|
+
* **첫 구멍에서 멈춘다.** 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다 — 보내는 쪽은
|
|
130
|
+
* `expectedSeq` 부터 다시 보내고, 뒤엣것은 그때 함께 온다.
|
|
131
|
+
*/
|
|
132
|
+
let take = items.length;
|
|
133
|
+
let advanced;
|
|
134
|
+
let gap;
|
|
135
|
+
if (numbered) {
|
|
136
|
+
/* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */
|
|
137
|
+
const key = (0, hook_contract_js_1.pushCursorKey)(instanceId, scope);
|
|
138
|
+
let cursor = (0, hook_contract_js_1.seqOf)(ref.liveCursor, key);
|
|
139
|
+
take = 0;
|
|
140
|
+
for (const item of items) {
|
|
141
|
+
let verdict;
|
|
142
|
+
try {
|
|
143
|
+
verdict = (0, ops_contract_1.checkSequence)(cursor, item.seq);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'bad sequence number' } };
|
|
147
|
+
}
|
|
148
|
+
if (verdict.kind === 'gap') {
|
|
149
|
+
gap = { expectedSeq: verdict.expectedSeq, lastSeq: verdict.lastSeq };
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
cursor = verdict.lastSeq;
|
|
153
|
+
take++;
|
|
154
|
+
}
|
|
155
|
+
/* 앞의 것을 하나도 못 받았으면 커서를 올릴 것이 없다. */
|
|
156
|
+
if (take > 0 && typeof cursor === 'number')
|
|
157
|
+
advanced = { scope: key, seq: cursor };
|
|
158
|
+
}
|
|
159
|
+
const records = items.slice(0, take).map(i => i.record);
|
|
106
160
|
if (!records.length) {
|
|
107
|
-
/*
|
|
108
|
-
|
|
161
|
+
/*
|
|
162
|
+
* 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. 다만 첫 봉투부터
|
|
163
|
+
* 구멍이면 그것은 받은 것이 없는 것이고, 그 앞부터 달라고 해야 한다.
|
|
164
|
+
*/
|
|
165
|
+
if (gap)
|
|
166
|
+
return gapOutcome(scope, gap);
|
|
167
|
+
if (advanced)
|
|
168
|
+
await saveSeq(store, ref, advanced.scope, advanced.seq);
|
|
169
|
+
return {
|
|
170
|
+
status: ops_contract_1.WEBHOOK_STATUS.ok,
|
|
171
|
+
body: { ok: true, offered: 0, applied: 0, duplicates: 0, rejected: [], ...seqBody(scope, advanced, numbered) }
|
|
172
|
+
};
|
|
109
173
|
}
|
|
110
174
|
let result;
|
|
111
175
|
try {
|
|
112
176
|
result = ingest(instanceId, records);
|
|
113
177
|
}
|
|
114
178
|
catch (e) {
|
|
115
|
-
return { status:
|
|
179
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } };
|
|
116
180
|
}
|
|
117
|
-
|
|
181
|
+
const rejected = result.rejected ?? [];
|
|
182
|
+
if (result.applied === 0 && result.duplicates === 0 && rejected.length === 0) {
|
|
118
183
|
/*
|
|
119
184
|
* 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다.
|
|
120
|
-
* 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.
|
|
121
|
-
*
|
|
185
|
+
* 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.
|
|
186
|
+
*
|
|
187
|
+
* **커서를 올리지 않는다.** 한 건도 안 들어갔는데 번호를 올리면 그 번호는 다시 오지 않고, 트윈이
|
|
188
|
+
* 뜬 뒤에 그 구간이 비어 있게 된다.
|
|
189
|
+
*/
|
|
190
|
+
return {
|
|
191
|
+
status: ops_contract_1.WEBHOOK_STATUS.notLive,
|
|
192
|
+
body: {
|
|
193
|
+
ok: false,
|
|
194
|
+
error: `twin "${instanceId}" is not running live — nothing was applied`,
|
|
195
|
+
offered: records.length,
|
|
196
|
+
retryAfterMs: NOT_LIVE_RETRY_MS
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (advanced)
|
|
201
|
+
await saveSeq(store, ref, advanced.scope, advanced.seq);
|
|
202
|
+
/*
|
|
203
|
+
* **구멍이 있으면 그것이 답을 정한다.** 앞의 것은 이미 들어갔으므로 `lastSeq` 까지는 보냄으로 표시하게
|
|
204
|
+
* 하고, 떨어진 것이 있으면 그것도 함께 싣는다 — 두 소식이 한 응답에 들어가야 보내는 쪽이 둘 다 한다.
|
|
205
|
+
*/
|
|
206
|
+
if (gap)
|
|
207
|
+
return gapOutcome(scope, gap, rejected, advanced);
|
|
208
|
+
if (rejected.length) {
|
|
209
|
+
/*
|
|
210
|
+
* **다시 보내라고 하지 않는다.** 떨어진 것은 모양이 틀린 것이라 같은 것을 다시 보내도 또 떨어지고,
|
|
211
|
+
* 함께 간 나머지는 이미 들어갔으므로 두 번 들어간다.
|
|
122
212
|
*/
|
|
123
213
|
return {
|
|
124
|
-
status:
|
|
125
|
-
body: {
|
|
214
|
+
status: ops_contract_1.WEBHOOK_STATUS.partial,
|
|
215
|
+
body: {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: `${rejected.length} of ${records.length} records did not pass validation`,
|
|
218
|
+
offered: records.length,
|
|
219
|
+
applied: result.applied,
|
|
220
|
+
duplicates: result.duplicates,
|
|
221
|
+
rejected,
|
|
222
|
+
...seqBody(scope, advanced, numbered)
|
|
223
|
+
}
|
|
126
224
|
};
|
|
127
225
|
}
|
|
128
226
|
return {
|
|
129
|
-
status:
|
|
130
|
-
body: {
|
|
227
|
+
status: ops_contract_1.WEBHOOK_STATUS.ok,
|
|
228
|
+
body: {
|
|
229
|
+
ok: true,
|
|
230
|
+
offered: records.length,
|
|
231
|
+
applied: result.applied,
|
|
232
|
+
duplicates: result.duplicates,
|
|
233
|
+
rejected: [],
|
|
234
|
+
...seqBody(scope, advanced, numbered)
|
|
235
|
+
}
|
|
131
236
|
};
|
|
132
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* 구멍을 알리는 응답 — **앞의 것은 받았다는 것을 함께 말한다.**
|
|
240
|
+
*
|
|
241
|
+
* `lastSeq` 를 빼면 보내는 쪽은 이미 들어간 것까지 다시 보내고, 그것이 매번 중복으로 걸러지면서
|
|
242
|
+
* 처리량만 먹는다.
|
|
243
|
+
*/
|
|
244
|
+
function gapOutcome(scope, gap, rejected = [], advanced) {
|
|
245
|
+
return {
|
|
246
|
+
status: ops_contract_1.WEBHOOK_STATUS.sequenceGap,
|
|
247
|
+
body: {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: 'sequence gap',
|
|
250
|
+
scope,
|
|
251
|
+
expectedSeq: gap.expectedSeq,
|
|
252
|
+
lastSeq: advanced?.seq ?? gap.lastSeq,
|
|
253
|
+
...(rejected.length ? { rejected } : {})
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* 트윈이 안 돌 때 얼마나 쉬라고 할 것인가.
|
|
259
|
+
*
|
|
260
|
+
* 사람이 트윈을 띄워야 풀리므로 초 단위로 다시 두드릴 이유가 없다. 그렇다고 길게 잡으면 트윈이 뜬 뒤
|
|
261
|
+
* 그만큼 늦게 이어진다.
|
|
262
|
+
*/
|
|
263
|
+
const NOT_LIVE_RETRY_MS = 30_000;
|
|
264
|
+
/**
|
|
265
|
+
* 응답에 번호를 어떻게 적나 — **확인한 것과 확인하지 않은 것을 구별한다.**
|
|
266
|
+
*
|
|
267
|
+
* 번호를 매기지 않는 원본은 확인할 것이 없다. 그것을 응답에서 「이상 없다」와 같아 보이게 두면, 어느
|
|
268
|
+
* 연결이 연속성을 지키고 있는지 아무도 알 수 없다.
|
|
269
|
+
*/
|
|
270
|
+
function seqBody(scope, advanced, numbered) {
|
|
271
|
+
if (!numbered)
|
|
272
|
+
return { sequenceChecked: false };
|
|
273
|
+
return { sequenceChecked: true, scope, lastSeq: advanced?.seq };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* 커서에 번호를 적는다 — **반영한 뒤에만.**
|
|
277
|
+
*
|
|
278
|
+
* 넣기 전에 올리면, 넣다가 실패했을 때 그 번호는 다시 오지 않는다. 보내는 쪽은 200 을 못 받았으니 다시
|
|
279
|
+
* 보내겠지만, 그때는 커서가 이미 지나가 있어 `behind` 로 읽히고 버려진다.
|
|
280
|
+
*
|
|
281
|
+
* 적지 못하는 것은 사실 유입을 무르는 이유가 아니다 — 이미 들어간 사실은 그대로 두고, 다음 번호가 오면
|
|
282
|
+
* `gap` 으로 드러난다. 그래서 실패를 던지지 않고 남긴다.
|
|
283
|
+
*/
|
|
284
|
+
async function saveSeq(store, ref, scope, seq) {
|
|
285
|
+
const next = (0, hook_contract_js_1.withSeq)(ref.liveCursor, scope, seq);
|
|
286
|
+
ref.liveCursor = next;
|
|
287
|
+
await store
|
|
288
|
+
.saveCursor(ref.id, next)
|
|
289
|
+
.catch(err => (0, log_js_1.twinWarn)(`[twin-hook] 번호를 커서에 적지 못했다 (${scope}=${seq}) — ${err?.message ?? err}`));
|
|
290
|
+
}
|
|
133
291
|
//# sourceMappingURL=reference-hook.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reference-hook.js","sourceRoot":"","sources":["../../../server/service/reference/reference-hook.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 연결된 시스템이 **밀어 주는** 사실을 받는 자리 — 웹훅.\n *\n * 규약(상태 코드 · 비밀값 · 대상 확인)은 `hook-contract.ts` 에 있다. 여기는 그것을 써서 찾고 · 확인하고 ·\n * 커넥터에게 옮기게 하고 · 유입으로 넘기는 순서만 맡는다.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 지금 모든 커넥터가 주기적으로 물어봅니다(폴링). 그 방식은 두 가지를 치릅니다. 연결된 시스템이 값을\n * 갱신하는 주기보다 자주 물으면 같은 값을 되풀이 받고, 그쪽 서버에 그만큼 부담을 줍니다. 실제로\n * 태양광 발전소에 10초마다 물으면서 10분마다 바뀌는 값을 받아 왔습니다.\n *\n * 연결된 시스템이 「바뀌었다」를 말해 줄 수 있으면 그것을 받는 것이 낫습니다. 이 파일이 그 입구입니다.\n *\n * ── 규약 ────────────────────────────────────────────────────────────────────\n *\n * 주소 POST /domain/<도메인>/twin/hook/<연결이름>/<트윈>\n * 인증 연결 설정에 저장한 비밀값(`hookSecret`)을 헤더로 보낸다\n * X-Twin-Hook-Secret: <비밀값> 또는 Authorization: Bearer <비밀값>\n * 본문 연결된 시스템이 정한 모양 그대로. 우리 어휘로 옮기는 것은 커넥터의 일이다\n * 응답 아래 §`hookStatus`\n *\n * ── 왜 사람 인증(JWT)이 아닌가 ──────────────────────────────────────────────\n * 밀어 주는 쪽은 사람이 아닙니다. 우리 토큰을 발급받아 갱신하며 관리하라고 요구하면 대부분의 현장에서\n * 연동이 서지 않습니다. 그래서 연결마다 비밀값을 두고 그것으로 확인합니다.\n *\n * **비밀값이 없는 연결은 훅을 받지 않습니다.** 기본이 거부입니다 — 인증 없는 입구를 열어 두는 것보다\n * 훅을 못 쓰는 것이 낫습니다.\n *\n * ── 왜 커넥터가 옮기나 (시나리오가 아니라) ──────────────────────────────────\n * 연결된 시스템의 웹훅은 그쪽이 정한 고정된 모양으로 옵니다. 그 모양을 아는 것은 그 커넥터이고,\n * 옮기는 판단은 코드여야 테스트로 지킬 수 있습니다. 실제로 지금 커넥터들이 코드로 판단합니다 —\n * 통신이 끊긴 설비의 값은 보내지 않고, 잰 시각이 나아가지 않은 줄은 거릅니다. 그 판단을 변환식으로\n * 옮기면 약해지고 테스트가 없어집니다.\n *\n * ── 밀어 주기만으로는 안 된다 ───────────────────────────────────────────────\n * 훅은 반드시 놓칩니다 — 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가 끊겼을 때. 그래서\n * 훅을 쓰는 연결도 **드문 폴링을 유지합니다.** 폴링의 역할이 「값을 가져오는 것」에서 「놓친 것이 있나\n * 확인하는 것」으로 바뀌는 것입니다. 그 주기는 연결 설정이 정합니다.\n *\n * ── 같은 것이 두 번 오는 것 ─────────────────────────────────────────────────\n * 밀어 주는 방식은 재전송이 정상입니다(그쪽이 우리 응답을 못 받으면 다시 보냅니다). 그것은 유입\n * 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지\n * 함께 알립니다.\n */\nimport { getRepository } from '@things-factory/shell'\n\nimport { TwinReference } from './twin-reference.js'\nimport { getAdapter } from './reference-adapter.js'\nimport { HOOK_STATUS, producedInstance, secretFromHeaders, secretMatches, type HookOutcome } from './hook-contract.js'\n\n/**\n * 훅 하나를 처리한다 — 찾고 · 확인하고 · 커넥터에게 옮기게 하고 · 유입으로 넘긴다.\n *\n * 유입 자체(`ingest`)는 부르는 쪽이 넘겨 준다: 이 파일이 엔진을 직접 부르면 시험할 때 엔진이 필요해지고,\n * 그러면 규약을 확인하는 시험을 쓸 수 없다.\n */\nexport async function handleHook(args: {\n domainId: string\n source: string\n instanceId: string\n headers: Record<string, unknown>\n body: unknown\n /** 레코드를 트윈에 넣는다 — 받은 수와 버린 수를 돌려준다. */\n ingest: (instanceId: string, records: unknown[]) => { applied: number; duplicates: number; rejected: number }\n}): Promise<HookOutcome> {\n const { domainId, source, instanceId, headers, body, ingest } = args\n\n const ref = await getRepository(TwinReference)\n .findOne({ where: { domain: { id: domainId }, source } })\n .catch(() => null)\n if (!ref) {\n return { status: HOOK_STATUS.unknownTarget, body: { ok: false, error: `unknown connection \"${source}\"` } }\n }\n\n const cfg: any = (ref as any).connectionConfig ?? {}\n const expected = String(cfg.hookSecret ?? '').trim()\n if (!expected) {\n /* 비밀값을 선언하지 않은 연결은 훅을 받지 않는다 — 기본이 거부다. */\n return {\n status: HOOK_STATUS.badSecret,\n body: {\n ok: false,\n error: `connection \"${source}\" declares no hookSecret — set one in the connection settings before pushing to it`\n }\n }\n }\n if (!secretMatches(secretFromHeaders(headers), expected)) {\n return { status: HOOK_STATUS.badSecret, body: { ok: false, error: 'hook secret does not match' } }\n }\n\n const site = producedInstance(ref as any, instanceId)\n if (!site) {\n return {\n status: HOOK_STATUS.unknownTarget,\n body: { ok: false, error: `twin \"${instanceId}\" was not created by connection \"${source}\"` }\n }\n }\n\n const adapter: any = getAdapter(String((ref as any).adapterType ?? ''))\n if (typeof adapter?.handleInbound !== 'function') {\n return {\n status: HOOK_STATUS.unsupported,\n body: { ok: false, error: `connector \"${(ref as any).adapterType}\" cannot take pushed events (no handleInbound)` }\n }\n }\n\n let records: unknown[]\n try {\n records = adapter.handleInbound(cfg, site, body, headers) ?? []\n } catch (e: any) {\n /* 옮기지 못한 것은 그쪽 모양이 바뀐 것이다 — 다시 보내도 같으므로 재시도를 부르지 않는다. */\n return { status: HOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'cannot translate the payload' } }\n }\n if (!Array.isArray(records)) {\n return { status: HOOK_STATUS.badPayload, body: { ok: false, error: 'the connector did not return a list of records' } }\n }\n if (!records.length) {\n /* 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. */\n return { status: HOOK_STATUS.ok, body: { ok: true, offered: 0, applied: 0, duplicates: 0, rejected: 0 } }\n }\n\n let result: { applied: number; duplicates: number; rejected: number }\n try {\n result = ingest(instanceId, records)\n } catch (e: any) {\n return { status: HOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } }\n }\n\n if (result.applied === 0 && result.duplicates === 0 && records.length > 0 && result.rejected === 0) {\n /*\n * 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다.\n * 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다. 그래서 재시도로 풀리지 않는다는 것을 코드로\n * 말한다 — 사람이 트윈을 띄워야 한다.\n */\n return {\n status: HOOK_STATUS.notLive,\n body: { ok: false, error: `twin \"${instanceId}\" is not running live — nothing was applied`, offered: records.length }\n }\n }\n\n return {\n status: HOOK_STATUS.ok,\n body: { ok: true, offered: records.length, applied: result.applied, duplicates: result.duplicates, rejected: result.rejected }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"reference-hook.js","sourceRoot":"","sources":["../../../server/service/reference/reference-hook.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 연결된 시스템이 **밀어 주는** 사실을 받는 자리 — 웹훅.\n *\n * 규약(상태 코드 · 비밀값 · 대상 확인)은 `hook-contract.ts` 에 있다. 여기는 그것을 써서 찾고 · 확인하고 ·\n * 커넥터에게 옮기게 하고 · 유입으로 넘기는 순서만 맡는다.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 지금 모든 커넥터가 주기적으로 물어봅니다(폴링). 그 방식은 두 가지를 치릅니다. 연결된 시스템이 값을\n * 갱신하는 주기보다 자주 물으면 같은 값을 되풀이 받고, 그쪽 서버에 그만큼 부담을 줍니다. 실제로\n * 태양광 발전소에 10초마다 물으면서 10분마다 바뀌는 값을 받아 왔습니다.\n *\n * 연결된 시스템이 「바뀌었다」를 말해 줄 수 있으면 그것을 받는 것이 낫습니다. 이 파일이 그 입구입니다.\n *\n * ── 규약 ────────────────────────────────────────────────────────────────────\n *\n * 주소 POST /domain/<도메인>/twin/hook/<연결이름>/<트윈>\n * 인증 연결 설정에 저장한 비밀값(`hookSecret`)을 헤더로 보낸다\n * X-Twin-Hook-Secret: <비밀값> 또는 Authorization: Bearer <비밀값>\n * 본문 연결된 시스템이 정한 모양 그대로. 우리 어휘로 옮기는 것은 커넥터의 일이다\n * 응답 아래 §`hookStatus`\n *\n * ── 왜 사람 인증(JWT)이 아닌가 ──────────────────────────────────────────────\n * 밀어 주는 쪽은 사람이 아닙니다. 우리 토큰을 발급받아 갱신하며 관리하라고 요구하면 대부분의 현장에서\n * 연동이 서지 않습니다. 그래서 연결마다 비밀값을 두고 그것으로 확인합니다.\n *\n * **비밀값이 없는 연결은 훅을 받지 않습니다.** 기본이 거부입니다 — 인증 없는 입구를 열어 두는 것보다\n * 훅을 못 쓰는 것이 낫습니다.\n *\n * ── 왜 커넥터가 옮기나 (시나리오가 아니라) ──────────────────────────────────\n * 연결된 시스템의 웹훅은 그쪽이 정한 고정된 모양으로 옵니다. 그 모양을 아는 것은 그 커넥터이고,\n * 옮기는 판단은 코드여야 테스트로 지킬 수 있습니다. 실제로 지금 커넥터들이 코드로 판단합니다 —\n * 통신이 끊긴 설비의 값은 보내지 않고, 잰 시각이 나아가지 않은 줄은 거릅니다. 그 판단을 변환식으로\n * 옮기면 약해지고 테스트가 없어집니다.\n *\n * ── 밀어 주기만으로는 안 된다 ───────────────────────────────────────────────\n * 훅은 반드시 놓칩니다 — 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가 끊겼을 때. 그래서\n * 훅을 쓰는 연결도 **드문 폴링을 유지합니다.** 폴링의 역할이 「값을 가져오는 것」에서 「놓친 것이 있나\n * 확인하는 것」으로 바뀌는 것입니다. 그 주기는 연결 설정이 정합니다.\n *\n * ── 같은 것이 두 번 오는 것 ─────────────────────────────────────────────────\n * 밀어 주는 방식은 재전송이 정상입니다(그쪽이 우리 응답을 못 받으면 다시 보냅니다). 그것은 유입\n * 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지\n * 함께 알립니다.\n */\nimport { WEBHOOK_STATUS, checkSequence } from '@operato/ops-contract'\n\nimport { twinWarn } from '../../engine/log.js'\n\nimport { getAdapter, type InboundBatch } from './reference-adapter.js'\nimport { authorizeHook, producedInstance, pushCursorKey, seqOf, withSeq, type HookOutcome } from './hook-contract.js'\n\n/**\n * 훅 하나를 처리한다 — 찾고 · 확인하고 · 커넥터에게 옮기게 하고 · 유입으로 넘긴다.\n *\n * 유입 자체(`ingest`)는 부르는 쪽이 넘겨 준다: 이 파일이 엔진을 직접 부르면 시험할 때 엔진이 필요해지고,\n * 그러면 규약을 확인하는 시험을 쓸 수 없다.\n */\nexport async function handleHook(args: {\n domainId: string\n source: string\n instanceId: string\n headers: Record<string, unknown>\n body: unknown\n /**\n * 받은 바이트 그대로 — 서명을 확인하려면 이것이 있어야 한다.\n *\n * 파싱한 객체를 다시 문자열로 만들면 키 순서와 공백이 달라져 서명이 맞지 않는다. 없으면 서명을 쓰는\n * 요청은 거절된다(§`authorizeHook`).\n */\n rawBody?: string\n /** 서명 창을 재는 기준 시각. 부르는 쪽이 준다 — 여기서 읽으면 시험이 시계에 매인다. */\n nowMs?: number\n /** 레코드를 트윈에 넣는다 — 받은 수와 **떨어진 것들**을 돌려준다. */\n ingest: (instanceId: string, records: unknown[]) => IngestResult\n /**\n * 저장소를 대신 쓰는 자리 — **시험이 재기동을 넣을 수 있게.**\n *\n * `ingest` 를 부르는 쪽이 넘겨 주는 것과 같은 이유다(위 §). 커서는 재기동을 넘어 살아야 하는데, 한\n * 프로세스 안에서만 확인하면 「적히나」는 보이지만 「다시 세우면 읽히나」는 안 보인다. 그 둘이 갈린\n * 결함을 이 저장소가 이미 겪었다.\n */\n store: HookStore\n}): Promise<HookOutcome> {\n const { domainId, source, instanceId, headers, body, rawBody, ingest } = args\n const nowMs = args.nowMs ?? Date.now()\n const { store } = args\n\n const ref = await store.load(domainId, source)\n if (!ref) {\n return { status: WEBHOOK_STATUS.unknownTarget, body: { ok: false, error: `unknown connection \"${source}\"` } }\n }\n\n const cfg: any = (ref as any).connectionConfig ?? {}\n const expected = String(cfg.hookSecret ?? '').trim()\n if (!expected) {\n /* 비밀값을 선언하지 않은 연결은 훅을 받지 않는다 — 기본이 거부다. */\n return {\n status: WEBHOOK_STATUS.badSecret,\n body: {\n ok: false,\n error: `connection \"${source}\" declares no hookSecret — set one in the connection settings before pushing to it`\n }\n }\n }\n const auth = authorizeHook({ headers, rawBody, secret: expected, nowMs })\n if (auth.ok === false) {\n return { status: WEBHOOK_STATUS.badSecret, body: { ok: false, error: auth.reason } }\n }\n\n const site = producedInstance(ref as any, instanceId)\n if (!site) {\n return {\n status: WEBHOOK_STATUS.unknownTarget,\n body: { ok: false, error: `twin \"${instanceId}\" was not created by connection \"${source}\"` }\n }\n }\n\n const adapter: any = getAdapter(String((ref as any).adapterType ?? ''))\n if (typeof adapter?.handleInbound !== 'function') {\n return {\n status: WEBHOOK_STATUS.unsupported,\n body: { ok: false, error: `connector \"${(ref as any).adapterType}\" cannot take pushed events (no handleInbound)` }\n }\n }\n\n let batch: InboundBatch\n try {\n batch = adapter.handleInbound(cfg, site, body, headers)\n } catch (e: any) {\n /* 옮기지 못한 것은 그쪽 모양이 바뀐 것이다 — 다시 보내도 같으므로 재시도를 부르지 않는다. */\n return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'cannot translate the payload' } }\n }\n const items = batch?.items\n if (!Array.isArray(items)) {\n return {\n status: WEBHOOK_STATUS.badPayload,\n body: { ok: false, error: 'the connector did not return a list of items' }\n }\n }\n\n /*\n * **번호 확인** — 이 자리가 프레임워크인 이유는 밀어 주는 모든 연결에 같은 규율이라야 하기 때문이다.\n * 커넥터마다 만들면 한 곳이 빠지고, 빠진 그 연결에서만 사실이 없어진다.\n *\n * 번호를 매기지 않는 원본(chef · ppms)은 `seq` 를 주지 않고, 그때는 확인하지 않는다. 확인하지 않았다는\n * 것은 응답에 남긴다 — 「확인했고 이상 없다」와 구별되어야 한다.\n */\n const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined\n const withSeqCount = items.filter(i => typeof i?.seq === 'number').length\n if (withSeqCount && withSeqCount !== items.length) {\n /* 섞이면 빠진 것이 있는지 판단할 근거가 없다. 반쯤 확인한 것을 확인했다고 말하지 않는다. */\n return {\n status: WEBHOOK_STATUS.badPayload,\n body: { ok: false, error: `mixed batch — ${withSeqCount} of ${items.length} items carry a sequence number` }\n }\n }\n const numbered = withSeqCount > 0 && scope !== undefined\n\n /*\n * **첫 구멍에서 멈춘다.** 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다 — 보내는 쪽은\n * `expectedSeq` 부터 다시 보내고, 뒤엣것은 그때 함께 온다.\n */\n let take = items.length\n let advanced: { scope: string; seq: number } | undefined\n let gap: { expectedSeq: number; lastSeq: number } | undefined\n if (numbered) {\n /* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */\n const key = pushCursorKey(instanceId, scope as string)\n let cursor = seqOf((ref as any).liveCursor, key)\n take = 0\n for (const item of items) {\n let verdict\n try {\n verdict = checkSequence(cursor, item.seq as number)\n } catch (e: any) {\n return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'bad sequence number' } }\n }\n if (verdict.kind === 'gap') {\n gap = { expectedSeq: verdict.expectedSeq, lastSeq: verdict.lastSeq }\n break\n }\n cursor = verdict.lastSeq\n take++\n }\n /* 앞의 것을 하나도 못 받았으면 커서를 올릴 것이 없다. */\n if (take > 0 && typeof cursor === 'number') advanced = { scope: key, seq: cursor }\n }\n\n const records = items.slice(0, take).map(i => i.record)\n\n if (!records.length) {\n /*\n * 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. 다만 첫 봉투부터\n * 구멍이면 그것은 받은 것이 없는 것이고, 그 앞부터 달라고 해야 한다.\n */\n if (gap) return gapOutcome(scope, gap)\n if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq)\n return {\n status: WEBHOOK_STATUS.ok,\n body: { ok: true, offered: 0, applied: 0, duplicates: 0, rejected: [], ...seqBody(scope, advanced, numbered) }\n }\n }\n\n let result: IngestResult\n try {\n result = ingest(instanceId, records)\n } catch (e: any) {\n return { status: WEBHOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } }\n }\n const rejected = result.rejected ?? []\n\n if (result.applied === 0 && result.duplicates === 0 && rejected.length === 0) {\n /*\n * 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다.\n * 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.\n *\n * **커서를 올리지 않는다.** 한 건도 안 들어갔는데 번호를 올리면 그 번호는 다시 오지 않고, 트윈이\n * 뜬 뒤에 그 구간이 비어 있게 된다.\n */\n return {\n status: WEBHOOK_STATUS.notLive,\n body: {\n ok: false,\n error: `twin \"${instanceId}\" is not running live — nothing was applied`,\n offered: records.length,\n retryAfterMs: NOT_LIVE_RETRY_MS\n }\n }\n }\n\n if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq)\n\n /*\n * **구멍이 있으면 그것이 답을 정한다.** 앞의 것은 이미 들어갔으므로 `lastSeq` 까지는 보냄으로 표시하게\n * 하고, 떨어진 것이 있으면 그것도 함께 싣는다 — 두 소식이 한 응답에 들어가야 보내는 쪽이 둘 다 한다.\n */\n if (gap) return gapOutcome(scope, gap, rejected, advanced)\n\n if (rejected.length) {\n /*\n * **다시 보내라고 하지 않는다.** 떨어진 것은 모양이 틀린 것이라 같은 것을 다시 보내도 또 떨어지고,\n * 함께 간 나머지는 이미 들어갔으므로 두 번 들어간다.\n */\n return {\n status: WEBHOOK_STATUS.partial,\n body: {\n ok: false,\n error: `${rejected.length} of ${records.length} records did not pass validation`,\n offered: records.length,\n applied: result.applied,\n duplicates: result.duplicates,\n rejected,\n ...seqBody(scope, advanced, numbered)\n }\n }\n }\n\n return {\n status: WEBHOOK_STATUS.ok,\n body: {\n ok: true,\n offered: records.length,\n applied: result.applied,\n duplicates: result.duplicates,\n rejected: [],\n ...seqBody(scope, advanced, numbered)\n }\n }\n}\n\n/**\n * 유입이 돌려주는 것.\n *\n * 떨어진 것을 **수가 아니라 목록으로** 받는다. 수만 받으면 보내는 쪽에 「몇 건이 떨어졌다」밖에 말할 수\n * 없고, 그러면 무엇을 따로 두어야 할지 알 수 없어 배치 전체를 다시 보내거나 전부 버린다.\n */\nexport interface IngestResult {\n applied: number\n duplicates: number\n rejected: { record: unknown; errors: string[] }[]\n}\n\n/**\n * 구멍을 알리는 응답 — **앞의 것은 받았다는 것을 함께 말한다.**\n *\n * `lastSeq` 를 빼면 보내는 쪽은 이미 들어간 것까지 다시 보내고, 그것이 매번 중복으로 걸러지면서\n * 처리량만 먹는다.\n */\nfunction gapOutcome(\n scope: string | undefined,\n gap: { expectedSeq: number; lastSeq: number },\n rejected: { record: unknown; errors: string[] }[] = [],\n advanced?: { scope: string; seq: number }\n): HookOutcome {\n return {\n status: WEBHOOK_STATUS.sequenceGap,\n body: {\n ok: false,\n error: 'sequence gap',\n scope,\n expectedSeq: gap.expectedSeq,\n lastSeq: advanced?.seq ?? gap.lastSeq,\n ...(rejected.length ? { rejected } : {})\n }\n }\n}\n\n/**\n * 트윈이 안 돌 때 얼마나 쉬라고 할 것인가.\n *\n * 사람이 트윈을 띄워야 풀리므로 초 단위로 다시 두드릴 이유가 없다. 그렇다고 길게 잡으면 트윈이 뜬 뒤\n * 그만큼 늦게 이어진다.\n */\nconst NOT_LIVE_RETRY_MS = 30_000\n\n/**\n * 응답에 번호를 어떻게 적나 — **확인한 것과 확인하지 않은 것을 구별한다.**\n *\n * 번호를 매기지 않는 원본은 확인할 것이 없다. 그것을 응답에서 「이상 없다」와 같아 보이게 두면, 어느\n * 연결이 연속성을 지키고 있는지 아무도 알 수 없다.\n */\nfunction seqBody(\n scope: string | undefined,\n advanced: { scope: string; seq: number } | undefined,\n numbered: boolean\n): Record<string, unknown> {\n if (!numbered) return { sequenceChecked: false }\n return { sequenceChecked: true, scope, lastSeq: advanced?.seq }\n}\n\n/**\n * 커서에 번호를 적는다 — **반영한 뒤에만.**\n *\n * 넣기 전에 올리면, 넣다가 실패했을 때 그 번호는 다시 오지 않는다. 보내는 쪽은 200 을 못 받았으니 다시\n * 보내겠지만, 그때는 커서가 이미 지나가 있어 `behind` 로 읽히고 버려진다.\n *\n * 적지 못하는 것은 사실 유입을 무르는 이유가 아니다 — 이미 들어간 사실은 그대로 두고, 다음 번호가 오면\n * `gap` 으로 드러난다. 그래서 실패를 던지지 않고 남긴다.\n */\nasync function saveSeq(\n store: HookStore,\n ref: { id: string; liveCursor?: unknown },\n scope: string,\n seq: number\n): Promise<void> {\n const next = withSeq(ref.liveCursor, scope, seq)\n ref.liveCursor = next\n await store\n .saveCursor(ref.id, next)\n .catch(err => twinWarn(`[twin-hook] 번호를 커서에 적지 못했다 (${scope}=${seq}) — ${err?.message ?? err}`))\n}\n\n/**\n * 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것.\n *\n * 이 둘만 밖으로 낸다. 나머지는 인자만 보고 답하므로 시험이 데이터베이스를 세우지 않아도 된다.\n */\nexport interface HookStore {\n load(domainId: string, source: string): Promise<any | null>\n saveCursor(refId: string, cursor: unknown): Promise<void>\n}\n\n"]}
|