@things-factory/headless-twin 10.1.7 → 10.1.8
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/README.md +19 -0
- package/dist-server/routes.js +4 -1
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/reference/attention-lane.d.ts +12 -0
- package/dist-server/service/reference/attention-lane.js +15 -0
- package/dist-server/service/reference/attention-lane.js.map +1 -0
- package/dist-server/service/reference/reference-adapter.d.ts +6 -0
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-hook.d.ts +2 -1
- package/dist-server/service/reference/reference-hook.js +8 -1
- package/dist-server/service/reference/reference-hook.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +4 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/package.json +2 -2
- package/server/routes.ts +4 -1
- package/server/service/reference/attention-lane.ts +21 -0
- package/server/service/reference/reference-adapter.ts +6 -0
- package/server/service/reference/reference-hook.ts +11 -3
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +5 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reference-hook.js","sourceRoot":"","sources":["../../../server/service/reference/reference-hook.ts"],"names":[],"mappings":";;;AA2DA,gCAkRC;AA7UD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wDAA4E;AAE5E,gDAA8C;AAE9C,iEAAsE;AACtE,yDAA8E;AAC9E,uDAA8E;AAArE,qHAAA,iBAAiB,OAAA;AAC1B,yDAAqI;AAErI;;;;;GAKG;AACI,KAAK,UAAU,UAAU,CAAC,IAkChC;IACC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAA;IACpF,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,EAAE,CAAA;IACtC,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAA;IAEtB,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;IAC9C,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,EAAE,MAAM,EAAE,6BAAc,CAAC,aAAa,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,uBAAuB,MAAM,GAAG,EAAE,EAAE,CAAA;IAC/G,CAAC;IAED,MAAM,GAAG,GAAS,GAAW,CAAC,gBAAgB,IAAI,EAAE,CAAA;IACpD,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IACpD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,2CAA2C;QAC3C,OAAO;YACL,MAAM,EAAE,6BAAc,CAAC,SAAS;YAChC,IAAI,EAAE;gBACJ,EAAE,EAAE,KAAK;gBACT,KAAK,EAAE,eAAe,MAAM,oFAAoF;aACjH;SACF,CAAA;IACH,CAAC;IACD,MAAM,IAAI,GAAG,IAAA,gCAAa,EAAC,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAA;IACzE,IAAI,IAAI,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QACtB,OAAO,EAAE,MAAM,EAAE,6BAAc,CAAC,SAAS,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE,CAAA;IACtF,CAAC;IAED,MAAM,IAAI,GAAG,IAAA,mCAAgB,EAAC,GAAU,EAAE,UAAU,CAAC,CAAA;IACrD,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO;YACL,MAAM,EAAE,6BAAc,CAAC,aAAa;YACpC,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,UAAU,oCAAoC,MAAM,GAAG,EAAE;SAC7F,CAAA;IACH,CAAC;IAED,MAAM,OAAO,GAAQ,IAAA,iCAAU,EAAC,MAAM,CAAE,GAAW,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,CAAA;IACvE,IAAI,OAAO,OAAO,EAAE,aAAa,KAAK,UAAU,EAAE,CAAC;QACjD,OAAO;YACL,MAAM,EAAE,6BAAc,CAAC,WAAW;YAClC,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,cAAe,GAAW,CAAC,WAAW,gDAAgD,EAAE;SACnH,CAAA;IACH,CAAC;IAED,IAAI,KAAmB,CAAA;IACvB,IAAI,CAAC;QACH,KAAK,GAAG,OAAO,CAAC,aAAa,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;IACzD,CAAC;IAAC,OAAO,CAAM,EAAE,CAAC;QAChB,yDAAyD;QACzD,OAAO,EAAE,MAAM,EAAE,6BAAc,CAAC,UAAU,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,IAAI,8BAA8B,EAAE,EAAE,CAAA;IACxH,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,EAAE,KAAK,CAAA;IAC1B,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO;YACL,MAAM,EAAE,6BAAc,CAAC,UAAU;YACjC,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,8CAA8C,EAAE;SAC3E,CAAA;IACH,CAAC;IAED;;;;;;OAMG;IACH,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;IACpG,qDAAqD;IACrD,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAA,gCAAa,EAAC,UAAU,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IAChE,MAAM,KAAK,GAAG,IAAA,iCAAc,EAAC;QAC3B,KAAK;QACL,KAAK;QACL,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,IAAA,wBAAK,EAAE,GAAW,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;KAC/D,CAAC,CAAA;IACF,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;QAClB,8BAA8B;QAC9B,OAAO,EAAE,MAAM,EAAE,6BAAc,CAAC,UAAU,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,EAAE,CAAA;IACzF,CAAC;IACD,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,CAAA;IAC/B,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,CAAA;IACrB;;;;;;OAMG;IACH,IAAI,KAAK,CAAC,iBAAiB,EAAE,CAAC;QAC5B,IAAA,iBAAQ,EACN,kBAAkB,UAAU,OAAO,KAAK,IAAI,SAAS,MAAM,KAAK,CAAC,WAAW,mBAAmB;YAC7F,OAAO,KAAK,CAAC,iBAAiB,sCAAsC;YACpE,4BAA4B,CAC/B,CAAA;IACH,CAAC;IACD,MAAM,QAAQ,GAAG,KAAK,CAAC,WAAW,KAAK,SAAS,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;IAE5G,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAA;IAE7B,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;QACpB;;;WAGG;QACH,IAAI,GAAG,EAAE,CAAC;YACR,IAAI,KAAK;gBAAE,KAAK,EAAE,CAAC,KAAK,CAAC,CAAA;YACzB,OAAO,UAAU,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;QAC/B,CAAC;QACD,IAAI,QAAQ;YAAE,MAAM,OAAO,CAAC,KAAK,EAAE,GAAU,EAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAA;QAC5F,OAAO;YACL,MAAM,EAAE,6BAAc,CAAC,EAAE;YACzB,IAAI,EAAE;gBACJ,EAAE,EAAE,IAAI;gBACR,OAAO,EAAE,CAAC;gBACV,OAAO,EAAE,CAAC;gBACV,UAAU,EAAE,CAAC;gBACb,UAAU,EAAE,CAAC;gBACb,QAAQ,EAAE,EAAE;gBACZ,GAAG,OAAO,CAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,iBAAiB,CAAC;aAC/D;SACF,CAAA;IACH,CAAC;IAED,IAAI,MAAoB,CAAA;IACxB,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAA;IACtC,CAAC;IAAC,OAAO,CAAM,EAAE,CAAC;QAChB,OAAO,EAAE,MAAM,EAAE,6BAAc,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,IAAI,eAAe,EAAE,EAAE,CAAA;IACrG,CAAC;IACD,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAA;IACtC;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,SAAS,GAAG,IAAA,oCAAiB,EAAC,QAAQ,EAAE,KAAkD,CAAC,CAAA;IAEjG,IAAI,MAAM,CAAC,OAAO,KAAK,CAAC,IAAI,MAAM,CAAC,UAAU,KAAK,CAAC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;QACnG;;;;;;;;;;;;;;;;;;;;;;;;;WAyBG;QACH,IAAA,iBAAQ,EACN,gBAAgB,UAAU,OAAO,OAAO,CAAC,MAAM,oBAAoB;YACjE,GAAG,iBAAiB,GAAG,IAAI,8CAA8C;YACzE,gDAAgD,CACnD,CAAA;QAED,OAAO;YACL,MAAM,EAAE,6BAAc,CAAC,OAAO;YAC9B,IAAI,EAAE;gBACJ,EAAE,EAAE,KAAK;gBACT,KAAK,EAAE,SAAS,UAAU,6CAA6C;gBACvE,OAAO,EAAE,OAAO,CAAC,MAAM;gBACvB,YAAY,EAAE,iBAAiB;aAChC;SACF,CAAA;IACH,CAAC;IAED,IAAI,QAAQ;QAAE,MAAM,OAAO,CAAC,KAAK,EAAE,GAAU,EAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAA;IAE5F;;;OAGG;IACH,IAAI,GAAG,EAAE,CAAC;QACR,IAAI,KAAK;YAAE,KAAK,EAAE,CAAC,KAAK,CAAC,CAAA;QACzB,OAAO,UAAU,CAAC,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAA;IACpD,CAAC;IAED,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpB;;;WAGG;QACH,OAAO;YACL,MAAM,EAAE,6BAAc,CAAC,OAAO;YAC9B,IAAI,EAAE;gBACJ,EAAE,EAAE,KAAK;gBACT,KAAK,EAAE,GAAG,QAAQ,CAAC,MAAM,OAAO,OAAO,CAAC,MAAM,kCAAkC;gBAChF,OAAO,EAAE,OAAO,CAAC,MAAM;gBACvB,OAAO,EAAE,MAAM,CAAC,OAAO;gBACvB,UAAU,EAAE,MAAM,CAAC,UAAU;gBAC7B,UAAU,EAAE,MAAM,CAAC,UAAU,IAAI,CAAC;gBAClC,QAAQ,EAAE,SAAS;gBACnB,GAAG,OAAO,CAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,iBAAiB,CAAC;aAC/D;SACF,CAAA;IACH,CAAC;IAED,OAAO;QACL,MAAM,EAAE,6BAAc,CAAC,EAAE;QACzB,IAAI,EAAE;YACJ,EAAE,EAAE,IAAI;YACR,OAAO,EAAE,OAAO,CAAC,MAAM;YACvB,OAAO,EAAE,MAAM,CAAC,OAAO;YACvB,UAAU,EAAE,MAAM,CAAC,UAAU;YAC7B;;;;eAIG;YACH,UAAU,EAAE,MAAM,CAAC,UAAU,IAAI,CAAC;YAClC,QAAQ,EAAE,EAAE;YACZ,GAAG,OAAO,CAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,iBAAiB,CAAC;SAC/D;KACF,CAAA;AACH,CAAC;AA0BD;;;;;GAKG;AACH;;;;;;GAMG;AACH,SAAS,UAAU,CACjB,KAAyB,EACzB,GAA6C;AAC7C;;;GAGG;AACH,WAAgC,EAAE,EAClC,QAAyC;IAEzC,OAAO;QACL,MAAM,EAAE,6BAAc,CAAC,WAAW;QAClC,IAAI,EAAE;YACJ,EAAE,EAAE,KAAK;YACT,KAAK,EAAE,cAAc;YACrB,KAAK;YACL,WAAW,EAAE,GAAG,CAAC,WAAW;YAC5B,OAAO,EAAE,QAAQ,EAAE,GAAG,IAAI,GAAG,CAAC,OAAO;YACrC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACzC;KACF,CAAA;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,iBAAiB,GAAG,MAAM,CAAA;AAEhC;;;;;GAKG;AACH;;;;;;GAMG;AACH,SAAS,OAAO,CACd,KAAyB,EACzB,QAAoD,EACpD,QAAiB,EACjB,iBAA0B;IAE1B,IAAI,CAAC,QAAQ;QAAE,OAAO,EAAE,eAAe,EAAE,KAAK,EAAE,CAAA;IAChD,OAAO;QACL,eAAe,EAAE,IAAI;QACrB,KAAK;QACL,OAAO,EAAE,QAAQ,EAAE,GAAG;QACtB;;;WAGG;QACH,GAAG,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACpD,CAAA;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,KAAK,UAAU,OAAO,CACpB,KAAgB,EAChB,GAAyC,EACzC,KAAa,EACb,GAAW,EACX,QAAiB;IAEjB,MAAM,IAAI,GAAG,IAAA,0BAAO,EAAC,GAAG,CAAC,UAAU,EAAE,KAAK,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAA;IAC1D,GAAG,CAAC,UAAU,GAAG,IAAI,CAAA;IACrB,MAAM,KAAK;SACR,UAAU,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,CAAC;SACxB,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,IAAA,iBAAQ,EAAC,+BAA+B,KAAK,IAAI,GAAG,OAAO,GAAG,EAAE,OAAO,IAAI,GAAG,EAAE,CAAC,CAAC,CAAA;AACpG,CAAC","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, type WebhookResponse } from '@operato/ops-contract'\n\nimport { twinWarn } from '../../engine/log.js'\n\nimport { getAdapter, type InboundBatch } from './reference-adapter.js'\nimport { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js'\nexport { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js'\nimport { authorizeHook, producedInstance, pushCursorKey, seqOf, takeInSequence, 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 * 여기서 backfill 을 직접 부르지 않는다. 이 파일은 `ingest`·`store` 를 인자로 받아 가볍게 유지한다\n * (그래야 테스트가 엔진 없이 이 판정을 확인할 수 있다). backfill 을 import 하면 그 규율이 깨진다.\n *\n * 기다리지 않는 것도 부르는 쪽의 몫이다 — 훅의 응답이 늦으면 보내는 쪽이 되풀이한다.\n */\n onGap?: (scope: string) => void\n /**\n * 저장소를 대신 쓰는 자리 — **시험이 재기동을 넣을 수 있게.**\n *\n * `ingest` 를 부르는 쪽이 넘겨 주는 것과 같은 이유다(위 §). 커서는 재기동을 넘어 살아야 하는데, 한\n * 프로세스 안에서만 확인하면 「적히나」는 보이지만 「다시 세우면 읽히나」는 안 보인다. 그 둘이 갈린\n * 결함을 이 저장소가 이미 겪었다.\n */\n store: HookStore\n}): Promise<HookOutcome> {\n const { domainId, source, instanceId, headers, body, rawBody, ingest, onGap } = 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 * 판정은 `takeInSequence` 하나가 한다 — **backfill 하는 길도 같은 함수를 지난다.** 여기서 따로 만들면\n * 두 길이 다른 규칙으로 받고, 한쪽만 고쳐지는 날이 온다.\n */\n const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined\n /* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */\n const key = scope ? pushCursorKey(instanceId, scope) : undefined\n const taken = takeInSequence({\n items,\n scope,\n lastSeq: key ? seqOf((ref as any).liveCursor, key) : undefined\n })\n if (taken.refused) {\n /* 반쯤 확인한 것을 확인했다고 말하지 않는다. */\n return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: taken.refused } }\n }\n const numbered = taken.numbered\n const gap = taken.gap\n /*\n * 첫 봉투가 1번이 아니면 **그 사실을 남긴다.** 커서가 없는 연결에 21번이 오면 커서가 21 로 뛰고,\n * 그 앞 스무 건은 구멍으로도 안 세어진다. 실제로 plant 에서 그렇게 스무 건이 조용히 사라졌다.\n *\n * 손실이라고 말하지 않는다 — 늦게 붙은 트윈은 그 앞을 안 받기로 한 것이다. 수만 말하고 판단은\n * 사람이 한다.\n */\n if (taken.firstUnseenBefore) {\n twinWarn(\n `[twin-ingest] \"${instanceId}\" 의 ${scope ?? '(단위 없음)'} 를 ${taken.advancedSeq} 번부터 받기 시작했습니다 — ` +\n `그 앞 ${taken.firstUnseenBefore}건은 받은 적이 없습니다. 원본이 오래 돌고 있었으면 정상이고, ` +\n `보냈는데 못 받은 것이면 그만큼이 빠진 것입니다`\n )\n }\n const advanced = taken.advancedSeq !== undefined && key ? { scope: key, seq: taken.advancedSeq } : undefined\n\n const records = taken.records\n\n if (!records.length) {\n /*\n * 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. 다만 첫 봉투부터\n * 구멍이면 그것은 받은 것이 없는 것이고, 그 앞부터 달라고 해야 한다.\n */\n if (gap) {\n if (scope) onGap?.(scope)\n return gapOutcome(scope, gap)\n }\n if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq, taken.firstSeq)\n return {\n status: WEBHOOK_STATUS.ok,\n body: {\n ok: true,\n offered: 0,\n applied: 0,\n duplicates: 0,\n vocabulary: 0,\n rejected: [],\n ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore)\n }\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 * ── 떨어진 것에 **봉투 id** 를 붙인다 (2026-09-07 측정) ──────────────────────\n * 이 응답이 `{ record, errors }` 목록이었고, plant 은 그것을 **봉투 id 문자열 목록**으로 읽고\n * 있었다. `Array.isArray` 는 통과하므로 그쪽 guard 도 안 걸렸다 — id 를 맞추는데 객체라 하나도\n * 안 맞고 **묶음 전체가 「보냈음」으로 찍혔다.**\n *\n * 422 와 `ok: false` 를 보냈는데 그쪽 행은 재시도 0 · 오류 없음 · 보냈음이었다. 우리가 말했는데\n * 상대가 못 알아듣는 모양이면, 말한 것이 아니다.\n *\n * 그래서 봉투 id 를 함께 싣는다. 보내는 쪽이 자기 행을 표시할 수 있고 까닭도 남는다.\n *\n * **id 를 못 찾으면 레코드를 그대로 싣는다.** 그때는 무엇이 떨어졌는지 알 방법이 그것뿐이고,\n * 빈 id 를 실으면 보내는 쪽이 아무 행도 못 찾으면서 「알았다」고 여긴다.\n *\n * 짝은 **객체 동일성**으로 짓는다. `records` 는 `items` 의 순서와 원소를 그대로 들고 있고\n * (§`takeInSequence`), 그래서 커넥터를 고치지 않고도 봉투로 되짚을 수 있다. 값으로 비교하면\n * 같은 모양의 레코드 둘이 서로의 id 를 가져간다.\n */\n const forCaller = rejectedForCaller(rejected, items as { record?: unknown; eventId?: unknown }[])\n\n if (result.applied === 0 && result.duplicates === 0 && rejected.length === 0 && !result.vocabulary) {\n /*\n * 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다.\n * 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.\n *\n * **커서를 올리지 않는다.** 한 건도 안 들어갔는데 번호를 올리면 그 번호는 다시 오지 않고, 트윈이\n * 뜬 뒤에 그 구간이 비어 있게 된다.\n *\n * ── Vocabulary is why this test needs a fourth term (2026-09-07) ──────────\n * This inference reads an absence as a cause, and one absence is legitimate: a batch that was\n * entirely vocabulary applies no events, has no duplicates and rejects nothing, and yet\n * everything in it landed (§`applyMasterData`). Without the last term the receiver answers\n * 503 to correct traffic, and the sender then holds those facts and retries every thirty\n * seconds for as long as they keep arriving — the reply invents a reason (\"not running live\")\n * that the numbers do not support.\n *\n * Found by a test written for the response sum, not by reading this line.\n *\n * ── Why this branch has to say something out loud ─────────────────────────\n * Measured today: a 503 from here leaves **no trace on either side.** The sender maps it to\n * backpressure and returns without touching `attempts` or `lastError`, and this branch logged\n * nothing, so a sender re-offering the same batch every thirty seconds is invisible to both.\n *\n * That is also why \"it has not happened\" could not be established: `attempts 0` on the seven\n * vocabulary envelopes was offered as evidence and is not evidence, because this path never\n * increments that counter. Telling a sender to come back forever is worth one line.\n */\n twinWarn(\n `[twin-hook] \"${instanceId}\" 에 ${records.length}건이 왔는데 한 건도 앉지 않아 ` +\n `${NOT_LIVE_RETRY_MS / 1000}초 뒤 다시 오라고 답했습니다 — 트윈이 실시간으로 돌지 않는 것으로 봅니다. ` +\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, taken.firstSeq)\n\n /*\n * **구멍이 있으면 그것이 답을 정한다.** 앞의 것은 이미 들어갔으므로 `lastSeq` 까지는 보냄으로 표시하게\n * 하고, 떨어진 것이 있으면 그것도 함께 싣는다 — 두 소식이 한 응답에 들어가야 보내는 쪽이 둘 다 한다.\n */\n if (gap) {\n if (scope) onGap?.(scope)\n return gapOutcome(scope, gap, forCaller, advanced)\n }\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 vocabulary: result.vocabulary ?? 0,\n rejected: forCaller,\n ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore)\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 /*\n * Always reported, even as zero — the sender's sum has a term for it either way, and a field\n * that appears only sometimes makes an absent one indistinguishable from a receiver that\n * does not count vocabulary at all.\n */\n vocabulary: result.vocabulary ?? 0,\n rejected: [],\n ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore)\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 * How many records were vocabulary rather than events — **needed so the reply's sum closes.**\n *\n * `liveIngest` has counted this all along and this boundary dropped it, because the callback was\n * typed as `IngestResult` and vocabulary was not on it. So `offered` counted those records and\n * none of the other three did, and a sender checking the sum would have seen a loss on ordinary\n * traffic (§`WebhookResponse.offered`).\n *\n * Optional because an ingest that handles no vocabulary need not say zero, and a caller that\n * simply does not know must not be made to claim none.\n */\n vocabulary?: number\n}\n\n/**\n * 구멍을 알리는 응답 — **앞의 것은 받았다는 것을 함께 말한다.**\n *\n * `lastSeq` 를 빼면 보내는 쪽은 이미 들어간 것까지 다시 보내고, 그것이 매번 중복으로 걸러지면서\n * 처리량만 먹는다.\n */\n/*\n * 구멍을 본 자리에서 **바로 받는다** — 번호가 비었다는 것을 아는 가장 이른 순간이다.\n *\n * 기다리지 않는다. 훅의 응답이 이것 때문에 늦으면 보내는 쪽이 되풀이하고, 그러면 같은 봉투가 두 번\n * 온다. 그리고 같은 단위를 1분 안에 두 번 부르지 않는다(§`FILL_DEBOUNCE_MS`) — 밀어 받는 쪽이\n * 구멍 뒤의 봉투를 계속 보내면 그때마다 구멍 판정이 나기 때문이다.\n */\nfunction gapOutcome(\n scope: string | undefined,\n gap: { expectedSeq: number; lastSeq: number },\n /*\n * 떨어진 것 — 봉투 id 를 알면 그것을, 모르면 레코드를 싣는다(§`InboundItem.eventId`).\n * 보내는 쪽이 자기 행을 찾을 수 있어야 하고, 못 찾으면 무엇이 떨어졌는지라도 보여야 한다.\n */\n rejected: RejectedForCaller[] = [],\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 */\n/*\n * The return type is the contract's, narrowed to the fields this function fills.\n *\n * It was `Record<string, unknown>`, and because the result is **spread** into the reply, an index\n * signature slips past the excess-property check entirely — the four fields below were unverified\n * even after `HookOutcome.body` was typed. Naming them here is what makes the check reach them.\n */\nfunction seqBody(\n scope: string | undefined,\n advanced: { scope: string; seq: number } | undefined,\n numbered: boolean,\n firstUnseenBefore?: number\n): Pick<WebhookResponse, 'sequenceChecked' | 'scope' | 'lastSeq' | 'firstUnseenBefore'> {\n if (!numbered) return { sequenceChecked: false }\n return {\n sequenceChecked: true,\n scope,\n lastSeq: advanced?.seq,\n /*\n * 첫 봉투였고 그 앞을 못 봤으면 **그 수를 보내는 쪽에 돌려준다.** 안 돌려주면 그 사실이 아무 데도\n * 안 남는다 — 구멍으로도 안 세어지고 backfill 대상도 아니다(backfill 은 커서부터 묻는다).\n */\n ...(firstUnseenBefore ? { firstUnseenBefore } : {})\n }\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 firstSeq?: number\n): Promise<void> {\n const next = withSeq(ref.liveCursor, scope, seq, firstSeq)\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"]}
|
|
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, type WebhookResponse } from '@operato/ops-contract'\n\nimport { twinWarn } from '../../engine/log.js'\n\nimport { getAdapter, type InboundBatch } from './reference-adapter.js'\nimport { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js'\nexport { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js'\nimport { authorizeHook, producedInstance, pushCursorKey, seqOf, takeInSequence, withSeq, type HookOutcome } from './hook-contract.js'\n\ntype WebhookLane = 'FACTS' | 'ATTENTIONS'\nfunction webhookLaneOf(value: unknown): WebhookLane | undefined {\n return value === undefined || value === 'FACTS' ? 'FACTS' : value === 'ATTENTIONS' ? 'ATTENTIONS' : undefined\n}\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[], lane: WebhookLane) => IngestResult | Promise<IngestResult>\n /**\n * 구멍을 봤다 — **부르는 쪽이 무엇을 할지 정한다.**\n *\n * 여기서 backfill 을 직접 부르지 않는다. 이 파일은 `ingest`·`store` 를 인자로 받아 가볍게 유지한다\n * (그래야 테스트가 엔진 없이 이 판정을 확인할 수 있다). backfill 을 import 하면 그 규율이 깨진다.\n *\n * 기다리지 않는 것도 부르는 쪽의 몫이다 — 훅의 응답이 늦으면 보내는 쪽이 되풀이한다.\n */\n onGap?: (scope: string) => void\n /**\n * 저장소를 대신 쓰는 자리 — **시험이 재기동을 넣을 수 있게.**\n *\n * `ingest` 를 부르는 쪽이 넘겨 주는 것과 같은 이유다(위 §). 커서는 재기동을 넘어 살아야 하는데, 한\n * 프로세스 안에서만 확인하면 「적히나」는 보이지만 「다시 세우면 읽히나」는 안 보인다. 그 둘이 갈린\n * 결함을 이 저장소가 이미 겪었다.\n */\n store: HookStore\n}): Promise<HookOutcome> {\n const { domainId, source, instanceId, headers, body, rawBody, ingest, onGap } = 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 * 판정은 `takeInSequence` 하나가 한다 — **backfill 하는 길도 같은 함수를 지난다.** 여기서 따로 만들면\n * 두 길이 다른 규칙으로 받고, 한쪽만 고쳐지는 날이 온다.\n */\n const lane = webhookLaneOf((batch as any).lane)\n if (!lane) {\n return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: 'unknown webhook lane' } }\n }\n const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined\n /* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */\n const key = scope ? pushCursorKey(instanceId, scope) : undefined\n const taken = takeInSequence({\n items,\n scope,\n lastSeq: key ? seqOf((ref as any).liveCursor, key) : undefined\n })\n if (taken.refused) {\n /* 반쯤 확인한 것을 확인했다고 말하지 않는다. */\n return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: taken.refused } }\n }\n const numbered = taken.numbered\n const gap = taken.gap\n /*\n * 첫 봉투가 1번이 아니면 **그 사실을 남긴다.** 커서가 없는 연결에 21번이 오면 커서가 21 로 뛰고,\n * 그 앞 스무 건은 구멍으로도 안 세어진다. 실제로 plant 에서 그렇게 스무 건이 조용히 사라졌다.\n *\n * 손실이라고 말하지 않는다 — 늦게 붙은 트윈은 그 앞을 안 받기로 한 것이다. 수만 말하고 판단은\n * 사람이 한다.\n */\n if (taken.firstUnseenBefore) {\n twinWarn(\n `[twin-ingest] \"${instanceId}\" 의 ${scope ?? '(단위 없음)'} 를 ${taken.advancedSeq} 번부터 받기 시작했습니다 — ` +\n `그 앞 ${taken.firstUnseenBefore}건은 받은 적이 없습니다. 원본이 오래 돌고 있었으면 정상이고, ` +\n `보냈는데 못 받은 것이면 그만큼이 빠진 것입니다`\n )\n }\n const advanced = taken.advancedSeq !== undefined && key ? { scope: key, seq: taken.advancedSeq } : undefined\n\n const records = taken.records\n\n if (!records.length) {\n /*\n * 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. 다만 첫 봉투부터\n * 구멍이면 그것은 받은 것이 없는 것이고, 그 앞부터 달라고 해야 한다.\n */\n if (gap) {\n if (scope) onGap?.(scope)\n return gapOutcome(scope, gap)\n }\n if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq, taken.firstSeq)\n return {\n status: WEBHOOK_STATUS.ok,\n body: {\n ok: true,\n offered: 0,\n applied: 0,\n duplicates: 0,\n vocabulary: 0,\n rejected: [],\n ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore)\n }\n }\n }\n\n let result: IngestResult\n try {\n result = await ingest(instanceId, records, lane)\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 * ── 떨어진 것에 **봉투 id** 를 붙인다 (2026-09-07 측정) ──────────────────────\n * 이 응답이 `{ record, errors }` 목록이었고, plant 은 그것을 **봉투 id 문자열 목록**으로 읽고\n * 있었다. `Array.isArray` 는 통과하므로 그쪽 guard 도 안 걸렸다 — id 를 맞추는데 객체라 하나도\n * 안 맞고 **묶음 전체가 「보냈음」으로 찍혔다.**\n *\n * 422 와 `ok: false` 를 보냈는데 그쪽 행은 재시도 0 · 오류 없음 · 보냈음이었다. 우리가 말했는데\n * 상대가 못 알아듣는 모양이면, 말한 것이 아니다.\n *\n * 그래서 봉투 id 를 함께 싣는다. 보내는 쪽이 자기 행을 표시할 수 있고 까닭도 남는다.\n *\n * **id 를 못 찾으면 레코드를 그대로 싣는다.** 그때는 무엇이 떨어졌는지 알 방법이 그것뿐이고,\n * 빈 id 를 실으면 보내는 쪽이 아무 행도 못 찾으면서 「알았다」고 여긴다.\n *\n * 짝은 **객체 동일성**으로 짓는다. `records` 는 `items` 의 순서와 원소를 그대로 들고 있고\n * (§`takeInSequence`), 그래서 커넥터를 고치지 않고도 봉투로 되짚을 수 있다. 값으로 비교하면\n * 같은 모양의 레코드 둘이 서로의 id 를 가져간다.\n */\n const forCaller = rejectedForCaller(rejected, items as { record?: unknown; eventId?: unknown }[])\n\n if (result.applied === 0 && result.duplicates === 0 && rejected.length === 0 && !result.vocabulary) {\n /*\n * 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다.\n * 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.\n *\n * **커서를 올리지 않는다.** 한 건도 안 들어갔는데 번호를 올리면 그 번호는 다시 오지 않고, 트윈이\n * 뜬 뒤에 그 구간이 비어 있게 된다.\n *\n * ── Vocabulary is why this test needs a fourth term (2026-09-07) ──────────\n * This inference reads an absence as a cause, and one absence is legitimate: a batch that was\n * entirely vocabulary applies no events, has no duplicates and rejects nothing, and yet\n * everything in it landed (§`applyMasterData`). Without the last term the receiver answers\n * 503 to correct traffic, and the sender then holds those facts and retries every thirty\n * seconds for as long as they keep arriving — the reply invents a reason (\"not running live\")\n * that the numbers do not support.\n *\n * Found by a test written for the response sum, not by reading this line.\n *\n * ── Why this branch has to say something out loud ─────────────────────────\n * Measured today: a 503 from here leaves **no trace on either side.** The sender maps it to\n * backpressure and returns without touching `attempts` or `lastError`, and this branch logged\n * nothing, so a sender re-offering the same batch every thirty seconds is invisible to both.\n *\n * That is also why \"it has not happened\" could not be established: `attempts 0` on the seven\n * vocabulary envelopes was offered as evidence and is not evidence, because this path never\n * increments that counter. Telling a sender to come back forever is worth one line.\n */\n twinWarn(\n `[twin-hook] \"${instanceId}\" 에 ${records.length}건이 왔는데 한 건도 앉지 않아 ` +\n `${NOT_LIVE_RETRY_MS / 1000}초 뒤 다시 오라고 답했습니다 — 트윈이 실시간으로 돌지 않는 것으로 봅니다. ` +\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, taken.firstSeq)\n\n /*\n * **구멍이 있으면 그것이 답을 정한다.** 앞의 것은 이미 들어갔으므로 `lastSeq` 까지는 보냄으로 표시하게\n * 하고, 떨어진 것이 있으면 그것도 함께 싣는다 — 두 소식이 한 응답에 들어가야 보내는 쪽이 둘 다 한다.\n */\n if (gap) {\n if (scope) onGap?.(scope)\n return gapOutcome(scope, gap, forCaller, advanced)\n }\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 vocabulary: result.vocabulary ?? 0,\n rejected: forCaller,\n ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore)\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 /*\n * Always reported, even as zero — the sender's sum has a term for it either way, and a field\n * that appears only sometimes makes an absent one indistinguishable from a receiver that\n * does not count vocabulary at all.\n */\n vocabulary: result.vocabulary ?? 0,\n rejected: [],\n ...seqBody(scope, advanced, numbered, taken.firstUnseenBefore)\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 * How many records were vocabulary rather than events — **needed so the reply's sum closes.**\n *\n * `liveIngest` has counted this all along and this boundary dropped it, because the callback was\n * typed as `IngestResult` and vocabulary was not on it. So `offered` counted those records and\n * none of the other three did, and a sender checking the sum would have seen a loss on ordinary\n * traffic (§`WebhookResponse.offered`).\n *\n * Optional because an ingest that handles no vocabulary need not say zero, and a caller that\n * simply does not know must not be made to claim none.\n */\n vocabulary?: number\n}\n\n/**\n * 구멍을 알리는 응답 — **앞의 것은 받았다는 것을 함께 말한다.**\n *\n * `lastSeq` 를 빼면 보내는 쪽은 이미 들어간 것까지 다시 보내고, 그것이 매번 중복으로 걸러지면서\n * 처리량만 먹는다.\n */\n/*\n * 구멍을 본 자리에서 **바로 받는다** — 번호가 비었다는 것을 아는 가장 이른 순간이다.\n *\n * 기다리지 않는다. 훅의 응답이 이것 때문에 늦으면 보내는 쪽이 되풀이하고, 그러면 같은 봉투가 두 번\n * 온다. 그리고 같은 단위를 1분 안에 두 번 부르지 않는다(§`FILL_DEBOUNCE_MS`) — 밀어 받는 쪽이\n * 구멍 뒤의 봉투를 계속 보내면 그때마다 구멍 판정이 나기 때문이다.\n */\nfunction gapOutcome(\n scope: string | undefined,\n gap: { expectedSeq: number; lastSeq: number },\n /*\n * 떨어진 것 — 봉투 id 를 알면 그것을, 모르면 레코드를 싣는다(§`InboundItem.eventId`).\n * 보내는 쪽이 자기 행을 찾을 수 있어야 하고, 못 찾으면 무엇이 떨어졌는지라도 보여야 한다.\n */\n rejected: RejectedForCaller[] = [],\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 */\n/*\n * The return type is the contract's, narrowed to the fields this function fills.\n *\n * It was `Record<string, unknown>`, and because the result is **spread** into the reply, an index\n * signature slips past the excess-property check entirely — the four fields below were unverified\n * even after `HookOutcome.body` was typed. Naming them here is what makes the check reach them.\n */\nfunction seqBody(\n scope: string | undefined,\n advanced: { scope: string; seq: number } | undefined,\n numbered: boolean,\n firstUnseenBefore?: number\n): Pick<WebhookResponse, 'sequenceChecked' | 'scope' | 'lastSeq' | 'firstUnseenBefore'> {\n if (!numbered) return { sequenceChecked: false }\n return {\n sequenceChecked: true,\n scope,\n lastSeq: advanced?.seq,\n /*\n * 첫 봉투였고 그 앞을 못 봤으면 **그 수를 보내는 쪽에 돌려준다.** 안 돌려주면 그 사실이 아무 데도\n * 안 남는다 — 구멍으로도 안 세어지고 backfill 대상도 아니다(backfill 은 커서부터 묻는다).\n */\n ...(firstUnseenBefore ? { firstUnseenBefore } : {})\n }\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 firstSeq?: number\n): Promise<void> {\n const next = withSeq(ref.liveCursor, scope, seq, firstSeq)\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"]}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export declare class TwinLifecycleMutation {
|
|
2
2
|
/** 기동 — model 를 주면 provision+start, 없으면 레지스트리에 저장된 구조로 start. */
|
|
3
|
-
startTwinInstance(instanceId: string, context: ResolverContext, kind?: string, model?: any
|
|
3
|
+
startTwinInstance(instanceId: string, context: ResolverContext, kind?: string, model?: any,
|
|
4
|
+
/** 새 시뮬레이션은 재기동 때 저널을 이어야 한다. 엔진에는 이미 필수지만 이 API가 누락해
|
|
5
|
+
* zero-to-twin 경로가 기동 단계에서 막혔다. */
|
|
6
|
+
restartPolicy?: 'resync' | 'resume' | 'reset'): Promise<boolean>;
|
|
4
7
|
stopTwinInstance(instanceId: string, context: ResolverContext): Promise<boolean>;
|
|
5
8
|
/**
|
|
6
9
|
* 이 트윈이 **자기 과거를 어떻게 대할 것인가**를 다시 선언한다 (2026-08-20).
|
|
@@ -30,7 +30,7 @@ let TwinLifecycleMutation = class TwinLifecycleMutation {
|
|
|
30
30
|
* 트윈을 고치는 쪽이 아니다(ADR-0029 — 시뮬레이터는 원본의 한 종류).
|
|
31
31
|
*/
|
|
32
32
|
/** 기동 — model 를 주면 provision+start, 없으면 레지스트리에 저장된 구조로 start. */
|
|
33
|
-
async startTwinInstance(instanceId, context, kind, model) {
|
|
33
|
+
async startTwinInstance(instanceId, context, kind, model, restartPolicy) {
|
|
34
34
|
const domainId = context.state.domain.id;
|
|
35
35
|
// 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.
|
|
36
36
|
if (model) {
|
|
@@ -38,7 +38,7 @@ let TwinLifecycleMutation = class TwinLifecycleMutation {
|
|
|
38
38
|
예전에는 `kind ?? 'wms'` 로 메웠는데, 종류를 빠뜨린 야드/생산 트윈이 **창고로 알리지 않고 기동**했다. */
|
|
39
39
|
if (!kind)
|
|
40
40
|
throw new Error('kind is required when model is given — it decides which domain kernel runs this twin');
|
|
41
|
-
index_js_1.TwinEngine.start(instanceId, domainId, kind, await index_js_1.TwinEngine.withSpaceTimeBase(model, domainId));
|
|
41
|
+
index_js_1.TwinEngine.start(instanceId, domainId, kind, await index_js_1.TwinEngine.withSpaceTimeBase(model, domainId), restartPolicy);
|
|
42
42
|
}
|
|
43
43
|
else
|
|
44
44
|
await index_js_1.TwinEngine.startFromRegistry(domainId, instanceId);
|
|
@@ -194,8 +194,9 @@ tslib_1.__decorate([
|
|
|
194
194
|
tslib_1.__param(1, (0, type_graphql_1.Ctx)()),
|
|
195
195
|
tslib_1.__param(2, (0, type_graphql_1.Arg)('kind', { nullable: true })),
|
|
196
196
|
tslib_1.__param(3, (0, type_graphql_1.Arg)('model', type => shell_1.ScalarObject, { nullable: true })),
|
|
197
|
+
tslib_1.__param(4, (0, type_graphql_1.Arg)('restartPolicy', { nullable: true })),
|
|
197
198
|
tslib_1.__metadata("design:type", Function),
|
|
198
|
-
tslib_1.__metadata("design:paramtypes", [String, Object, String, Object]),
|
|
199
|
+
tslib_1.__metadata("design:paramtypes", [String, Object, String, Object, String]),
|
|
199
200
|
tslib_1.__metadata("design:returntype", Promise)
|
|
200
201
|
], TwinLifecycleMutation.prototype, "startTwinInstance", null);
|
|
201
202
|
tslib_1.__decorate([
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"twin-lifecycle-mutation.js","sourceRoot":"","sources":["../../../server/service/twin-lifecycle/twin-lifecycle-mutation.ts"],"names":[],"mappings":"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","sourcesContent":["import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'\n\nimport { ScalarObject, getRepository } from '@things-factory/shell'\n\nimport { TwinEngine } from '../../engine/index.js'\n/* 정책 읽기는 그 모듈에서 직접 가져온다 — 다른 소비처(`reference-resolver`)와 같은 길이다. */\nimport { readRestartPolicy } from '../../engine/restart-policy.js'\nimport { TwinInstance } from '../twin-instance/twin-instance.js'\n\nimport type { TwinModelDef } from '@operato/ops-contract'\n\n/* 도메인 어휘 카탈로그(시스템별 노드 타입) — 커널이 SSOT. 앱/UI 는 이걸 소싱하고 재선언하지 않는다(방언 금지).\n * 조합은 domain-catalog.ts 가 소유 — 리졸버와 서버측 소비자(board-ai 도구 등)가 같은 형태를 본다. */\nimport { domainCatalog } from './domain-catalog.js'\n\n/*\n * 트윈 인스턴스 생명주기 채널 — 프로비저닝(상향, ADR-0015).\n * State/Command/Scenario 는 이미 running 인 인스턴스를 다루지만, 이 채널은 인스턴스 자체를 만들고/설정하고/없앤다.\n * 생명주기: provision(save, 미기동) → start → stop → delete. 모두 domain 스코프(context.state.domain.id).\n * model(TwinModelDef+layout)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은 **원본이 선언하고**\n * (`TwinReference.connectionConfig.scenario`) 기동이 싣거나, 원본의 구동 능력으로 태운다(ADR-0029 §4·§8).\n */\n@Resolver()\nexport class TwinLifecycleMutation {\n /*\n * `saveTwinInstance` 는 **없앴다**(2026-08-08, ADR-0028).\n *\n * 트윈의 구조를 임의의 model 로 덮어쓰는 문이었고, 유일한 소비자는 손저작 편집기였다.\n * 구조는 원본에서 온다 — 템플릿(`createFromTemplate`)이나 커넥터(`ingestMaster`)가 그 길이다.\n * 문을 열어 두면 화면만 지워도 다음 소비자가 다시 생긴다.\n *\n * 시뮬 트윈을 세우는 것은 여전히 가능하다. 다만 그 입구는 **원본을 만드는 쪽**이지\n * 트윈을 고치는 쪽이 아니다(ADR-0029 — 시뮬레이터는 원본의 한 종류).\n */\n\n /** 기동 — model 를 주면 provision+start, 없으면 레지스트리에 저장된 구조로 start. */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => Boolean, {\n description: 'Start a twin runtime instance and begin the worker tick. If model is given it provisions first; otherwise it starts from the saved registry model.'\n })\n async startTwinInstance(\n @Arg('instanceId') instanceId: string,\n @Ctx() context: ResolverContext,\n @Arg('kind', { nullable: true }) kind?: string,\n @Arg('model', type => ScalarObject, { nullable: true }) model?: any\n ): Promise<boolean> {\n const domainId = context.state.domain.id\n // 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.\n if (model) {\n /* model 를 함께 주는 것은 프로비저닝이다 — 그러면 종류도 말해야 한다.\n 예전에는 `kind ?? 'wms'` 로 메웠는데, 종류를 빠뜨린 야드/생산 트윈이 **창고로 알리지 않고 기동**했다. */\n if (!kind) throw new Error('kind is required when model is given — it decides which domain kernel runs this twin')\n TwinEngine.start(instanceId, domainId, kind, await TwinEngine.withSpaceTimeBase(model as TwinModelDef, domainId))\n }\n else await TwinEngine.startFromRegistry(domainId, instanceId)\n return true\n }\n\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => Boolean, { description: 'Stop a running twin runtime instance by its id (only if it belongs to the current domain).' })\n async stopTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<boolean> {\n if (!TwinEngine.owns(context.state.domain.id, instanceId)) return false // 테넌트 격리 — 남의 도메인 트윈 정지 차단\n await TwinEngine.stop(context.state.domain.id, instanceId)\n return true\n }\n\n /**\n * 이 트윈이 **자기 과거를 어떻게 대할 것인가**를 다시 선언한다 (2026-08-20).\n *\n * ── 왜 이 문이 필요한가 ────────────────────────────────────────────────────\n * `restartPolicy` 는 사람이 하는 선언인데, 만들 때 한 번 정하면 **고칠 길이 없었다.** 그래서 「씨앗\n * 재현용」으로 세운 트윈이 나중에 사람들이 이력을 보는 트윈이 되어도 선언은 `reset` 으로 남았다.\n * 선언과 쓰임이 어긋난 채로 두면, 선언대로 구현하는 순간(저널 초기화) 이력을 잃는다.\n *\n * ── 지금 도는 것에는 손대지 않는다 ─────────────────────────────────────────\n * 이 값은 **다음 기동**의 규칙이다. 돌고 있는 런타임의 구동 방식을 바꾸려면 정지·기동이 필요하고,\n * 그 판단은 사람의 것이다(도는 트윈을 알리지 않고 갈아 끼우면 그 사이의 사실이 어디로 갔는지 알 수 없다).\n * 그래서 결과에 「지금 도는 것과 다르다」는 사실을 함께 낸다.\n */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => ScalarObject, {\n description:\n \"Re-declare how a twin treats its own past on the next boot: 'resync' (re-read from the source), 'resume' (carry its own journal forward) or 'reset' (reproduce from the seed, journal cleared). The value is read strictly — an unknown or legacy word is refused with the accepted words named, because a silent default would decide whether a twin's history survives. This changes the declaration only: a running runtime keeps the mode it was started with, and the result says so (appliesAtNextStart) rather than swapping it underneath.\"\n })\n async setTwinRestartPolicy(\n @Arg('instanceId') instanceId: string,\n @Arg('restartPolicy') restartPolicy: string,\n @Ctx() context: ResolverContext\n ): Promise<any> {\n const domainId = context.state.domain.id\n /* 값은 **엄격히** 읽는다 — 모르는 말을 기본값으로 메우면 그 트윈의 이력이 남을지가 알리지 않고 결정된다. */\n const policy = readRestartPolicy(restartPolicy, `twin \"${instanceId}\"`)\n const repo = getRepository(TwinInstance)\n const row = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })\n if (!row) throw new Error(`unknown twin \"${instanceId}\" in this domain — nothing was changed`)\n\n const before = row.restartPolicy\n if (before === policy) return { instanceId, restartPolicy: policy, changed: false, appliesAtNextStart: false }\n\n await repo.update({ id: row.id }, { restartPolicy: policy })\n /* 도는 런타임의 구동 방식은 그대로다 — 그 사실을 숨기지 않는다. */\n const running = !!TwinEngine.metrics(domainId, instanceId)\n return { instanceId, restartPolicy: policy, previous: before, changed: true, appliesAtNextStart: running }\n }\n\n /**\n * 트윈의 **이름을 다시 붙인다** (2026-08-26).\n *\n * ── 왜 이 문이 필요한가 ────────────────────────────────────────────────────\n * 이름은 트윈을 처음 세울 때 한 번 정해지고(`provision`), 다시 읽기는 그것을 건드리지 않는다. 그\n * 자체는 안전하다 — 사람이 붙인 이름을 연결이 덮어쓰면 안 된다. 그런데 그래서 **첫날의 이름에\n * 갇힌다.**\n *\n * 실제로 그런 일이 났다: 태양광 발전소를 붙일 때 커넥터가 아는 이름이 연결된 시스템의 코드\n * (`ES202307202202`)뿐이었다. 뒤에 사람이 부르는 이름(`호현에너지`)이 어디 있는지 알아내 커넥터를\n * 고쳤는데, 트윈 이름은 그대로였다. **연결된 시스템을 따라가는 트윈에서는 이름이 나중에 좋아진다** —\n * 커넥터가 나아질 때마다 이 일이 난다.\n *\n * 그때 남은 유일한 길이 트윈을 지우고 다시 세우는 것이었다. 그러면 지난 기록이 사라진다 — **이름\n * 하나 때문에 사실을 버리는 선택**이 된다. 그래서 이름만 바꾸는 문을 낸다.\n *\n * ── 연결이 따라오게 하지 않는다 ────────────────────────────────────────────\n * 다시 읽기가 이름을 따라가게 하면 사람이 붙인 이름을 덮어쓴다. 「사람이 손대지 않았으면 따라간다」가\n * 옳아 보이지만, 그러려면 「손댔다」는 표시를 새로 만들어야 하고 그것은 이름 하나 때문에 지기에 무거운\n * 빚이다. 사람이 바꾸는 길만 낸다.\n *\n * ── 빈 이름은 받지 않는다 ──────────────────────────────────────────────────\n * 이름을 지우는 것과 바꾸는 것은 다른 일이다. 빈 값을 받으면 그 트윈은 목록에서 식별자만 남는데,\n * 그것을 「이름을 바꿨다」로 보고하면 거짓이다. 지우고 싶으면 그 뜻의 문이 따로 있어야 한다.\n */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => ScalarObject, {\n description:\n 'Rename a twin instance. The name is set when the twin is first provisioned and a re-read never touches it, ' +\n 'so a twin stays with the name its connector knew on day one — often a code from the connected system. ' +\n 'This changes the name only; nothing about the twin data, its journal or its runtime is affected. ' +\n 'An empty name is refused: clearing a name and changing it are different acts.'\n })\n async renameTwinInstance(\n @Arg('instanceId') instanceId: string,\n @Arg('name') name: string,\n @Ctx() context: ResolverContext\n ): Promise<any> {\n const domainId = context.state.domain.id\n const next = String(name ?? '').trim()\n if (!next) throw new Error(`a twin name cannot be empty — \"${instanceId}\" was left as it was`)\n\n const repo = getRepository(TwinInstance)\n const row = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })\n if (!row) throw new Error(`unknown twin \"${instanceId}\" in this domain — nothing was changed`)\n\n const before = row.name ?? ''\n if (before === next) return { instanceId, name: next, changed: false }\n\n await repo.update({ id: row.id }, { name: next, updater: context.state.user })\n /* 도는 런타임은 이름을 쓰지 않는다(식별자로 돈다) — 그래서 정지·기동이 필요하지 않다. */\n return { instanceId, name: next, previous: before || null, changed: true }\n }\n\n /** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => Boolean, { description: 'Delete a twin instance: stop it, remove the registry entry and purge its event journal.' })\n async deleteTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<boolean> {\n await TwinEngine.remove(context.state.domain.id, instanceId)\n return true\n }\n\n /** 관리 목록 — 등록된 인스턴스 전체(kind/status/running/revision/노드·무버 수). 데이터 없으면 빈 배열. */\n /**\n * **이 공간의 트윈 id 들** — 한 현실을 여러 렌즈가 비추므로 공간을 물으면 그 렌즈 전부를 답한다.\n *\n * 화면이 전체 목록을 받아 걸러 쓰던 규칙을 서버로 모은다. 예측 화면은 같은 확장을 한 파일에서\n * **두 번** 복제하고 있었고, 규칙이 흩어지면 한쪽만 고쳐진다. 떠 있지 않은 것도 포함한다 —\n * 공간에 무엇이 있는지는 기동 여부와 다른 사실이다.\n */\n @Query(returns => [String], { description: 'Ids of the twins that live in a space (all its lenses), running or not.' })\n async twinSpaceInstanceIds(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise<string[]> {\n return TwinEngine.instanceIdsOfSpace(context.state.domain.id, spaceId)\n }\n\n @Query(returns => [ScalarObject], {\n description:\n 'List provisioned twin instances in the current domain with management metadata. Each row also carries `sync`: a one-line answer to \"is this twin keeping in step with the floor?\" — a verdict (syncing | mapping-blocked | no-traffic | not-connected | not-applicable | unknown), the accepted share of the last closed 10-minute window, and up to 36 closed windows of offered/rejected counts for a sparkline. Reasons and rejected-record samples are deliberately absent here; ask twinIngestHealth for one twin when you need them, because carrying raw records for every row would make this list heavy for no gain. acceptedRatio is null while the first window is still filling — a partially filled window under-counts, and drawing it as zero would read as \"nothing passed\".'\n })\n async twinInstanceList(@Ctx() context: ResolverContext): Promise<any[]> {\n const domainId = context.state.domain.id\n const list = await TwinEngine.list(domainId)\n /*\n * 동기화 요약을 **같은 응답에 얹는다.** 따로 질의하면 트윈 수만큼 왕복이 생기고(N+1), 그 사이 화면은\n * 판정 없는 목록을 먼저 그린다 — 사람이 그 순간을 「정상」으로 읽는다.\n */\n const brief = new Map(\n TwinEngine.ingestHealthBrief(\n domainId,\n list.map((x: any) => x.instanceId)\n ).map(b => [b.instanceId, b])\n )\n /*\n * **쓰기 사실도 같은 응답에 얹는다** (2026-08-20).\n *\n * 「이 트윈이 사실을 적고 있나」는 운영자가 트윈 화면에서 묻는 질문인데, 그 답은 벤치(슈퍼유저)의\n * 부하 계기판에만 있었다. 유입(`sync`)과 나란히 두는 이유는 둘이 한 사슬이기 때문이다 — 받은 것이\n * 있어도 적히지 않으면 저널이 비고, 그러면 성과·시간여행이 함께 빈다.\n *\n * 계기는 메모리라 왕복이 없다. **도는 트윈만** 답이 있고, 멈춘 트윈은 `null` 이다(0 이 아니다 —\n * 「쓰지 않았다」와 「돌지 않는다」는 다른 사실이다).\n */\n return list.map((x: any) => {\n const m = TwinEngine.metrics(domainId, x.instanceId)\n return {\n ...x,\n sync: brief.get(x.instanceId) ?? null,\n write: m ? { rate: m.journalRate, journaled: m.journaledTotal, backlog: m.backlog } : null\n }\n })\n }\n\n /** 단건 상세 — 프로비저닝 에디터용 model(구조+layout) 포함. */\n @Query(returns => ScalarObject, { nullable: true, description: 'Get one provisioned twin instance with its TwinModelDef (structure + layout) for editing.' })\n async twinInstanceDetail(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<any | null> {\n return TwinEngine.detail(context.state.domain.id, instanceId)\n }\n\n /**\n * 라이브(메모리) 인스턴스 id 목록 — 콘솔 셀렉터·포트폴리오용. **현재 도메인만**(테넌트 격리).\n * 과거엔 Object.keys(TwinEngine.instances) 로 전역 반환 → 다른 도메인 트윈이 종합현황/셀렉터에 새던 버그.\n * 도메인 스코프 목록(twinInstanceList 와 동일 소스)에서 running 만 추린다.\n */\n @Query(returns => [String], { description: 'List ids of running (in-memory) twin runtime instances in the current domain (tenant-isolated).' })\n async twinInstances(@Ctx() context: ResolverContext): Promise<string[]> {\n const list = await TwinEngine.list(context.state.domain.id)\n return list.filter((x: any) => x.running).map((x: any) => x.instanceId)\n }\n\n /**\n * 도메인 어휘 카탈로그 — 커널(레퍼런스) SSOT 를 그대로 노출. { wms:{label,locationTypes[]}, yms:…, mes:… }.\n * 프로비저닝 UI 는 이걸 소싱해 노드 타입/시스템을 채운다(앱 내 하드코딩 금지).\n */\n @Query(returns => ScalarObject, { description: 'Domain vocabulary catalog (per-system location location types) sourced from the twin kernel — SSOT for provisioning UI.' })\n twinDomainCatalog(): any {\n return domainCatalog()\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"twin-lifecycle-mutation.js","sourceRoot":"","sources":["../../../server/service/twin-lifecycle/twin-lifecycle-mutation.ts"],"names":[],"mappings":"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","sourcesContent":["import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql'\n\nimport { ScalarObject, getRepository } from '@things-factory/shell'\n\nimport { TwinEngine } from '../../engine/index.js'\n/* 정책 읽기는 그 모듈에서 직접 가져온다 — 다른 소비처(`reference-resolver`)와 같은 길이다. */\nimport { readRestartPolicy } from '../../engine/restart-policy.js'\nimport { TwinInstance } from '../twin-instance/twin-instance.js'\n\nimport type { TwinModelDef } from '@operato/ops-contract'\n\n/* 도메인 어휘 카탈로그(시스템별 노드 타입) — 커널이 SSOT. 앱/UI 는 이걸 소싱하고 재선언하지 않는다(방언 금지).\n * 조합은 domain-catalog.ts 가 소유 — 리졸버와 서버측 소비자(board-ai 도구 등)가 같은 형태를 본다. */\nimport { domainCatalog } from './domain-catalog.js'\n\n/*\n * 트윈 인스턴스 생명주기 채널 — 프로비저닝(상향, ADR-0015).\n * State/Command/Scenario 는 이미 running 인 인스턴스를 다루지만, 이 채널은 인스턴스 자체를 만들고/설정하고/없앤다.\n * 생명주기: provision(save, 미기동) → start → stop → delete. 모두 domain 스코프(context.state.domain.id).\n * model(TwinModelDef+layout)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은 **원본이 선언하고**\n * (`TwinReference.connectionConfig.scenario`) 기동이 싣거나, 원본의 구동 능력으로 태운다(ADR-0029 §4·§8).\n */\n@Resolver()\nexport class TwinLifecycleMutation {\n /*\n * `saveTwinInstance` 는 **없앴다**(2026-08-08, ADR-0028).\n *\n * 트윈의 구조를 임의의 model 로 덮어쓰는 문이었고, 유일한 소비자는 손저작 편집기였다.\n * 구조는 원본에서 온다 — 템플릿(`createFromTemplate`)이나 커넥터(`ingestMaster`)가 그 길이다.\n * 문을 열어 두면 화면만 지워도 다음 소비자가 다시 생긴다.\n *\n * 시뮬 트윈을 세우는 것은 여전히 가능하다. 다만 그 입구는 **원본을 만드는 쪽**이지\n * 트윈을 고치는 쪽이 아니다(ADR-0029 — 시뮬레이터는 원본의 한 종류).\n */\n\n /** 기동 — model 를 주면 provision+start, 없으면 레지스트리에 저장된 구조로 start. */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => Boolean, {\n description: 'Start a twin runtime instance and begin the worker tick. If model is given it provisions first; otherwise it starts from the saved registry model.'\n })\n async startTwinInstance(\n @Arg('instanceId') instanceId: string,\n @Ctx() context: ResolverContext,\n @Arg('kind', { nullable: true }) kind?: string,\n @Arg('model', type => ScalarObject, { nullable: true }) model?: any,\n /** 새 시뮬레이션은 재기동 때 저널을 이어야 한다. 엔진에는 이미 필수지만 이 API가 누락해\n * zero-to-twin 경로가 기동 단계에서 막혔다. */\n @Arg('restartPolicy', { nullable: true }) restartPolicy?: 'resync' | 'resume' | 'reset'\n ): Promise<boolean> {\n const domainId = context.state.domain.id\n // 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.\n if (model) {\n /* model 를 함께 주는 것은 프로비저닝이다 — 그러면 종류도 말해야 한다.\n 예전에는 `kind ?? 'wms'` 로 메웠는데, 종류를 빠뜨린 야드/생산 트윈이 **창고로 알리지 않고 기동**했다. */\n if (!kind) throw new Error('kind is required when model is given — it decides which domain kernel runs this twin')\n TwinEngine.start(instanceId, domainId, kind, await TwinEngine.withSpaceTimeBase(model as TwinModelDef, domainId), restartPolicy)\n }\n else await TwinEngine.startFromRegistry(domainId, instanceId)\n return true\n }\n\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => Boolean, { description: 'Stop a running twin runtime instance by its id (only if it belongs to the current domain).' })\n async stopTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<boolean> {\n if (!TwinEngine.owns(context.state.domain.id, instanceId)) return false // 테넌트 격리 — 남의 도메인 트윈 정지 차단\n await TwinEngine.stop(context.state.domain.id, instanceId)\n return true\n }\n\n /**\n * 이 트윈이 **자기 과거를 어떻게 대할 것인가**를 다시 선언한다 (2026-08-20).\n *\n * ── 왜 이 문이 필요한가 ────────────────────────────────────────────────────\n * `restartPolicy` 는 사람이 하는 선언인데, 만들 때 한 번 정하면 **고칠 길이 없었다.** 그래서 「씨앗\n * 재현용」으로 세운 트윈이 나중에 사람들이 이력을 보는 트윈이 되어도 선언은 `reset` 으로 남았다.\n * 선언과 쓰임이 어긋난 채로 두면, 선언대로 구현하는 순간(저널 초기화) 이력을 잃는다.\n *\n * ── 지금 도는 것에는 손대지 않는다 ─────────────────────────────────────────\n * 이 값은 **다음 기동**의 규칙이다. 돌고 있는 런타임의 구동 방식을 바꾸려면 정지·기동이 필요하고,\n * 그 판단은 사람의 것이다(도는 트윈을 알리지 않고 갈아 끼우면 그 사이의 사실이 어디로 갔는지 알 수 없다).\n * 그래서 결과에 「지금 도는 것과 다르다」는 사실을 함께 낸다.\n */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => ScalarObject, {\n description:\n \"Re-declare how a twin treats its own past on the next boot: 'resync' (re-read from the source), 'resume' (carry its own journal forward) or 'reset' (reproduce from the seed, journal cleared). The value is read strictly — an unknown or legacy word is refused with the accepted words named, because a silent default would decide whether a twin's history survives. This changes the declaration only: a running runtime keeps the mode it was started with, and the result says so (appliesAtNextStart) rather than swapping it underneath.\"\n })\n async setTwinRestartPolicy(\n @Arg('instanceId') instanceId: string,\n @Arg('restartPolicy') restartPolicy: string,\n @Ctx() context: ResolverContext\n ): Promise<any> {\n const domainId = context.state.domain.id\n /* 값은 **엄격히** 읽는다 — 모르는 말을 기본값으로 메우면 그 트윈의 이력이 남을지가 알리지 않고 결정된다. */\n const policy = readRestartPolicy(restartPolicy, `twin \"${instanceId}\"`)\n const repo = getRepository(TwinInstance)\n const row = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })\n if (!row) throw new Error(`unknown twin \"${instanceId}\" in this domain — nothing was changed`)\n\n const before = row.restartPolicy\n if (before === policy) return { instanceId, restartPolicy: policy, changed: false, appliesAtNextStart: false }\n\n await repo.update({ id: row.id }, { restartPolicy: policy })\n /* 도는 런타임의 구동 방식은 그대로다 — 그 사실을 숨기지 않는다. */\n const running = !!TwinEngine.metrics(domainId, instanceId)\n return { instanceId, restartPolicy: policy, previous: before, changed: true, appliesAtNextStart: running }\n }\n\n /**\n * 트윈의 **이름을 다시 붙인다** (2026-08-26).\n *\n * ── 왜 이 문이 필요한가 ────────────────────────────────────────────────────\n * 이름은 트윈을 처음 세울 때 한 번 정해지고(`provision`), 다시 읽기는 그것을 건드리지 않는다. 그\n * 자체는 안전하다 — 사람이 붙인 이름을 연결이 덮어쓰면 안 된다. 그런데 그래서 **첫날의 이름에\n * 갇힌다.**\n *\n * 실제로 그런 일이 났다: 태양광 발전소를 붙일 때 커넥터가 아는 이름이 연결된 시스템의 코드\n * (`ES202307202202`)뿐이었다. 뒤에 사람이 부르는 이름(`호현에너지`)이 어디 있는지 알아내 커넥터를\n * 고쳤는데, 트윈 이름은 그대로였다. **연결된 시스템을 따라가는 트윈에서는 이름이 나중에 좋아진다** —\n * 커넥터가 나아질 때마다 이 일이 난다.\n *\n * 그때 남은 유일한 길이 트윈을 지우고 다시 세우는 것이었다. 그러면 지난 기록이 사라진다 — **이름\n * 하나 때문에 사실을 버리는 선택**이 된다. 그래서 이름만 바꾸는 문을 낸다.\n *\n * ── 연결이 따라오게 하지 않는다 ────────────────────────────────────────────\n * 다시 읽기가 이름을 따라가게 하면 사람이 붙인 이름을 덮어쓴다. 「사람이 손대지 않았으면 따라간다」가\n * 옳아 보이지만, 그러려면 「손댔다」는 표시를 새로 만들어야 하고 그것은 이름 하나 때문에 지기에 무거운\n * 빚이다. 사람이 바꾸는 길만 낸다.\n *\n * ── 빈 이름은 받지 않는다 ──────────────────────────────────────────────────\n * 이름을 지우는 것과 바꾸는 것은 다른 일이다. 빈 값을 받으면 그 트윈은 목록에서 식별자만 남는데,\n * 그것을 「이름을 바꿨다」로 보고하면 거짓이다. 지우고 싶으면 그 뜻의 문이 따로 있어야 한다.\n */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => ScalarObject, {\n description:\n 'Rename a twin instance. The name is set when the twin is first provisioned and a re-read never touches it, ' +\n 'so a twin stays with the name its connector knew on day one — often a code from the connected system. ' +\n 'This changes the name only; nothing about the twin data, its journal or its runtime is affected. ' +\n 'An empty name is refused: clearing a name and changing it are different acts.'\n })\n async renameTwinInstance(\n @Arg('instanceId') instanceId: string,\n @Arg('name') name: string,\n @Ctx() context: ResolverContext\n ): Promise<any> {\n const domainId = context.state.domain.id\n const next = String(name ?? '').trim()\n if (!next) throw new Error(`a twin name cannot be empty — \"${instanceId}\" was left as it was`)\n\n const repo = getRepository(TwinInstance)\n const row = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })\n if (!row) throw new Error(`unknown twin \"${instanceId}\" in this domain — nothing was changed`)\n\n const before = row.name ?? ''\n if (before === next) return { instanceId, name: next, changed: false }\n\n await repo.update({ id: row.id }, { name: next, updater: context.state.user })\n /* 도는 런타임은 이름을 쓰지 않는다(식별자로 돈다) — 그래서 정지·기동이 필요하지 않다. */\n return { instanceId, name: next, previous: before || null, changed: true }\n }\n\n /** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */\n @Directive('@privilege(category: \"twin\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => Boolean, { description: 'Delete a twin instance: stop it, remove the registry entry and purge its event journal.' })\n async deleteTwinInstance(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<boolean> {\n await TwinEngine.remove(context.state.domain.id, instanceId)\n return true\n }\n\n /** 관리 목록 — 등록된 인스턴스 전체(kind/status/running/revision/노드·무버 수). 데이터 없으면 빈 배열. */\n /**\n * **이 공간의 트윈 id 들** — 한 현실을 여러 렌즈가 비추므로 공간을 물으면 그 렌즈 전부를 답한다.\n *\n * 화면이 전체 목록을 받아 걸러 쓰던 규칙을 서버로 모은다. 예측 화면은 같은 확장을 한 파일에서\n * **두 번** 복제하고 있었고, 규칙이 흩어지면 한쪽만 고쳐진다. 떠 있지 않은 것도 포함한다 —\n * 공간에 무엇이 있는지는 기동 여부와 다른 사실이다.\n */\n @Query(returns => [String], { description: 'Ids of the twins that live in a space (all its lenses), running or not.' })\n async twinSpaceInstanceIds(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise<string[]> {\n return TwinEngine.instanceIdsOfSpace(context.state.domain.id, spaceId)\n }\n\n @Query(returns => [ScalarObject], {\n description:\n 'List provisioned twin instances in the current domain with management metadata. Each row also carries `sync`: a one-line answer to \"is this twin keeping in step with the floor?\" — a verdict (syncing | mapping-blocked | no-traffic | not-connected | not-applicable | unknown), the accepted share of the last closed 10-minute window, and up to 36 closed windows of offered/rejected counts for a sparkline. Reasons and rejected-record samples are deliberately absent here; ask twinIngestHealth for one twin when you need them, because carrying raw records for every row would make this list heavy for no gain. acceptedRatio is null while the first window is still filling — a partially filled window under-counts, and drawing it as zero would read as \"nothing passed\".'\n })\n async twinInstanceList(@Ctx() context: ResolverContext): Promise<any[]> {\n const domainId = context.state.domain.id\n const list = await TwinEngine.list(domainId)\n /*\n * 동기화 요약을 **같은 응답에 얹는다.** 따로 질의하면 트윈 수만큼 왕복이 생기고(N+1), 그 사이 화면은\n * 판정 없는 목록을 먼저 그린다 — 사람이 그 순간을 「정상」으로 읽는다.\n */\n const brief = new Map(\n TwinEngine.ingestHealthBrief(\n domainId,\n list.map((x: any) => x.instanceId)\n ).map(b => [b.instanceId, b])\n )\n /*\n * **쓰기 사실도 같은 응답에 얹는다** (2026-08-20).\n *\n * 「이 트윈이 사실을 적고 있나」는 운영자가 트윈 화면에서 묻는 질문인데, 그 답은 벤치(슈퍼유저)의\n * 부하 계기판에만 있었다. 유입(`sync`)과 나란히 두는 이유는 둘이 한 사슬이기 때문이다 — 받은 것이\n * 있어도 적히지 않으면 저널이 비고, 그러면 성과·시간여행이 함께 빈다.\n *\n * 계기는 메모리라 왕복이 없다. **도는 트윈만** 답이 있고, 멈춘 트윈은 `null` 이다(0 이 아니다 —\n * 「쓰지 않았다」와 「돌지 않는다」는 다른 사실이다).\n */\n return list.map((x: any) => {\n const m = TwinEngine.metrics(domainId, x.instanceId)\n return {\n ...x,\n sync: brief.get(x.instanceId) ?? null,\n write: m ? { rate: m.journalRate, journaled: m.journaledTotal, backlog: m.backlog } : null\n }\n })\n }\n\n /** 단건 상세 — 프로비저닝 에디터용 model(구조+layout) 포함. */\n @Query(returns => ScalarObject, { nullable: true, description: 'Get one provisioned twin instance with its TwinModelDef (structure + layout) for editing.' })\n async twinInstanceDetail(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise<any | null> {\n return TwinEngine.detail(context.state.domain.id, instanceId)\n }\n\n /**\n * 라이브(메모리) 인스턴스 id 목록 — 콘솔 셀렉터·포트폴리오용. **현재 도메인만**(테넌트 격리).\n * 과거엔 Object.keys(TwinEngine.instances) 로 전역 반환 → 다른 도메인 트윈이 종합현황/셀렉터에 새던 버그.\n * 도메인 스코프 목록(twinInstanceList 와 동일 소스)에서 running 만 추린다.\n */\n @Query(returns => [String], { description: 'List ids of running (in-memory) twin runtime instances in the current domain (tenant-isolated).' })\n async twinInstances(@Ctx() context: ResolverContext): Promise<string[]> {\n const list = await TwinEngine.list(context.state.domain.id)\n return list.filter((x: any) => x.running).map((x: any) => x.instanceId)\n }\n\n /**\n * 도메인 어휘 카탈로그 — 커널(레퍼런스) SSOT 를 그대로 노출. { wms:{label,locationTypes[]}, yms:…, mes:… }.\n * 프로비저닝 UI 는 이걸 소싱해 노드 타입/시스템을 채운다(앱 내 하드코딩 금지).\n */\n @Query(returns => ScalarObject, { description: 'Domain vocabulary catalog (per-system location location types) sourced from the twin kernel — SSOT for provisioning UI.' })\n twinDomainCatalog(): any {\n return domainCatalog()\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/headless-twin",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.8",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh <heartyoh@hatiolab.com>",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"@things-factory/ingest": "^10.1.7",
|
|
36
36
|
"@things-factory/shell": "^10.1.7"
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "c402982e29aab3b49e56bea4e41948be6a749c7f"
|
|
39
39
|
}
|
package/server/routes.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { liveIngest } from './service/reference/live-ingest.js'
|
|
19
19
|
import { requestFill } from './service/reference/fill-loop.js'
|
|
20
20
|
import { handleHook } from './service/reference/reference-hook.js'
|
|
21
|
+
import { deliverAttention } from './service/reference/attention-lane.js'
|
|
21
22
|
import { databaseStore } from './service/reference/hook-store.js'
|
|
22
23
|
|
|
23
24
|
process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: any) => {
|
|
@@ -54,7 +55,9 @@ process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: an
|
|
|
54
55
|
* 유입 몸통은 `live-ingest.ts` 한 곳에 있다 — backfill 이 같은 것을 쓴다. 여기에 두면 두 길이
|
|
55
56
|
* 서로 다른 규칙으로 받게 되고, 한쪽만 수정되는 날이 온다.
|
|
56
57
|
*/
|
|
57
|
-
ingest: (instanceId, records) =>
|
|
58
|
+
ingest: (instanceId, records, lane) => lane === 'FACTS'
|
|
59
|
+
? liveIngest(domain.id, instanceId, records, 'hook')
|
|
60
|
+
: deliverAttention({ domainId: domain.id, source: String(ctx.params.source ?? ''), instanceId, records }),
|
|
58
61
|
/*
|
|
59
62
|
* 구멍을 본 그 자리에서 바로 받는다 — 번호가 비었다는 것을 아는 가장 이른 순간이다.
|
|
60
63
|
* 기다리지 않는다(§`requestFill`): 훅의 응답이 늦으면 보내는 쪽이 되풀이한다.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IngestResult } from './reference-hook.js'
|
|
2
|
+
|
|
3
|
+
export interface AttentionDelivery {
|
|
4
|
+
domainId: string
|
|
5
|
+
source: string
|
|
6
|
+
instanceId: string
|
|
7
|
+
records: unknown[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type AttentionHandler = (delivery: AttentionDelivery) => IngestResult | Promise<IngestResult>
|
|
11
|
+
let handler: AttentionHandler | undefined
|
|
12
|
+
|
|
13
|
+
/** Product modules opt into the ATTENTIONS lane; core owns only transport routing. */
|
|
14
|
+
export function registerAttentionHandler(next: AttentionHandler): void {
|
|
15
|
+
handler = next
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function deliverAttention(delivery: AttentionDelivery): Promise<IngestResult> {
|
|
19
|
+
if (!handler) throw new Error('ATTENTIONS lane has no registered product handler')
|
|
20
|
+
return await handler(delivery)
|
|
21
|
+
}
|
|
@@ -234,6 +234,12 @@ export interface SeqReport {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
export interface InboundBatch {
|
|
237
|
+
/**
|
|
238
|
+
* Receiver path for this batch. Omitted is FACTS, preserving every existing
|
|
239
|
+
* connector; ATTENTIONS is a product-level analysis input and must not be
|
|
240
|
+
* folded as a canonical fact.
|
|
241
|
+
*/
|
|
242
|
+
lane?: 'FACTS' | 'ATTENTIONS'
|
|
237
243
|
/**
|
|
238
244
|
* 옮긴 것들. 우리와 무관한 본문이면 빈 배열이다.
|
|
239
245
|
*
|
|
@@ -51,6 +51,11 @@ import { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js'
|
|
|
51
51
|
export { rejectedForCaller, type RejectedForCaller } from './hook-rejected.js'
|
|
52
52
|
import { authorizeHook, producedInstance, pushCursorKey, seqOf, takeInSequence, withSeq, type HookOutcome } from './hook-contract.js'
|
|
53
53
|
|
|
54
|
+
type WebhookLane = 'FACTS' | 'ATTENTIONS'
|
|
55
|
+
function webhookLaneOf(value: unknown): WebhookLane | undefined {
|
|
56
|
+
return value === undefined || value === 'FACTS' ? 'FACTS' : value === 'ATTENTIONS' ? 'ATTENTIONS' : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
/**
|
|
55
60
|
* 훅 하나를 처리한다 — 찾고 · 확인하고 · 커넥터에게 옮기게 하고 · 유입으로 넘긴다.
|
|
56
61
|
*
|
|
@@ -73,7 +78,7 @@ export async function handleHook(args: {
|
|
|
73
78
|
/** 서명 창을 재는 기준 시각. 부르는 쪽이 준다 — 여기서 읽으면 시험이 시계에 매인다. */
|
|
74
79
|
nowMs?: number
|
|
75
80
|
/** 레코드를 트윈에 넣는다 — 받은 수와 **떨어진 것들**을 돌려준다. */
|
|
76
|
-
ingest: (instanceId: string, records: unknown[]) => IngestResult
|
|
81
|
+
ingest: (instanceId: string, records: unknown[], lane: WebhookLane) => IngestResult | Promise<IngestResult>
|
|
77
82
|
/**
|
|
78
83
|
* 구멍을 봤다 — **부르는 쪽이 무엇을 할지 정한다.**
|
|
79
84
|
*
|
|
@@ -156,6 +161,10 @@ export async function handleHook(args: {
|
|
|
156
161
|
* 판정은 `takeInSequence` 하나가 한다 — **backfill 하는 길도 같은 함수를 지난다.** 여기서 따로 만들면
|
|
157
162
|
* 두 길이 다른 규칙으로 받고, 한쪽만 고쳐지는 날이 온다.
|
|
158
163
|
*/
|
|
164
|
+
const lane = webhookLaneOf((batch as any).lane)
|
|
165
|
+
if (!lane) {
|
|
166
|
+
return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: 'unknown webhook lane' } }
|
|
167
|
+
}
|
|
159
168
|
const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined
|
|
160
169
|
/* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */
|
|
161
170
|
const key = scope ? pushCursorKey(instanceId, scope) : undefined
|
|
@@ -214,7 +223,7 @@ export async function handleHook(args: {
|
|
|
214
223
|
|
|
215
224
|
let result: IngestResult
|
|
216
225
|
try {
|
|
217
|
-
result = ingest(instanceId, records)
|
|
226
|
+
result = await ingest(instanceId, records, lane)
|
|
218
227
|
} catch (e: any) {
|
|
219
228
|
return { status: WEBHOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } }
|
|
220
229
|
}
|
|
@@ -465,4 +474,3 @@ export interface HookStore {
|
|
|
465
474
|
load(domainId: string, source: string): Promise<any | null>
|
|
466
475
|
saveCursor(refId: string, cursor: unknown): Promise<void>
|
|
467
476
|
}
|
|
468
|
-
|
|
@@ -42,7 +42,10 @@ export class TwinLifecycleMutation {
|
|
|
42
42
|
@Arg('instanceId') instanceId: string,
|
|
43
43
|
@Ctx() context: ResolverContext,
|
|
44
44
|
@Arg('kind', { nullable: true }) kind?: string,
|
|
45
|
-
@Arg('model', type => ScalarObject, { nullable: true }) model?: any
|
|
45
|
+
@Arg('model', type => ScalarObject, { nullable: true }) model?: any,
|
|
46
|
+
/** 새 시뮬레이션은 재기동 때 저널을 이어야 한다. 엔진에는 이미 필수지만 이 API가 누락해
|
|
47
|
+
* zero-to-twin 경로가 기동 단계에서 막혔다. */
|
|
48
|
+
@Arg('restartPolicy', { nullable: true }) restartPolicy?: 'resync' | 'resume' | 'reset'
|
|
46
49
|
): Promise<boolean> {
|
|
47
50
|
const domainId = context.state.domain.id
|
|
48
51
|
// 시각 기준은 **공간**이 갖는다(테넌트 아님) — 교대의 HH:MM 을 어느 기준으로 읽나.
|
|
@@ -50,7 +53,7 @@ export class TwinLifecycleMutation {
|
|
|
50
53
|
/* model 를 함께 주는 것은 프로비저닝이다 — 그러면 종류도 말해야 한다.
|
|
51
54
|
예전에는 `kind ?? 'wms'` 로 메웠는데, 종류를 빠뜨린 야드/생산 트윈이 **창고로 알리지 않고 기동**했다. */
|
|
52
55
|
if (!kind) throw new Error('kind is required when model is given — it decides which domain kernel runs this twin')
|
|
53
|
-
TwinEngine.start(instanceId, domainId, kind, await TwinEngine.withSpaceTimeBase(model as TwinModelDef, domainId))
|
|
56
|
+
TwinEngine.start(instanceId, domainId, kind, await TwinEngine.withSpaceTimeBase(model as TwinModelDef, domainId), restartPolicy)
|
|
54
57
|
}
|
|
55
58
|
else await TwinEngine.startFromRegistry(domainId, instanceId)
|
|
56
59
|
return true
|