@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
|
@@ -4,28 +4,18 @@ export interface HookOutcome {
|
|
|
4
4
|
body: Record<string, unknown>;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* 응답 코드 규약 —
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* 500 우리 쪽 오류 재시도하면 될 수 있다
|
|
7
|
+
* 응답 코드 규약 — **계약이 정하고 여기서는 그것을 쓴다**(2026-08-31).
|
|
8
|
+
*
|
|
9
|
+
* 예전에는 이 표가 여기 있었고 보내는 쪽(operato-mes)에 또 있었다. 그래서 같은 409 를 양쪽이 다른
|
|
10
|
+
* 뜻으로 썼다 — 여기서는 「그 트윈이 실시간으로 돌지 않는다」, 저쪽에서는 「번호가 비었다」였다.
|
|
11
|
+
* 붙였다면 트윈이 안 도는 동안 보내는 쪽이 같은 구간을 계속 다시 보냈다.
|
|
12
|
+
*
|
|
13
|
+
* 판정표는 만드는 쪽과 읽는 쪽이 합의해야 하는 것이라 계약 층에 있다(`@operato/ops-contract`).
|
|
14
|
+
* `notLive` 가 409 에서 503 으로 옮겨진 이유도 그 파일에 적혀 있다.
|
|
15
|
+
*
|
|
16
|
+
* **여기서 이름을 바꿔 다시 내보내지 않는다.** 부르는 쪽은 `WEBHOOK_STATUS` 를 계약에서 바로 가져온다.
|
|
17
|
+
* 별명을 두면 같은 표에 이름이 둘이 되고, 다음 사람이 어느 쪽이 정본인지 물어야 한다.
|
|
19
18
|
*/
|
|
20
|
-
export declare const HOOK_STATUS: {
|
|
21
|
-
readonly ok: 200;
|
|
22
|
-
readonly badSecret: 401;
|
|
23
|
-
readonly unknownTarget: 404;
|
|
24
|
-
readonly badPayload: 400;
|
|
25
|
-
readonly notLive: 409;
|
|
26
|
-
readonly unsupported: 501;
|
|
27
|
-
readonly failed: 500;
|
|
28
|
-
};
|
|
29
19
|
/** 헤더에서 비밀값을 꺼낸다 — 두 가지 방식을 받는다(밀어 주는 쪽의 관습이 갈린다). */
|
|
30
20
|
export declare function secretFromHeaders(headers: Record<string, unknown> | undefined): string;
|
|
31
21
|
/**
|
|
@@ -35,6 +25,59 @@ export declare function secretFromHeaders(headers: Record<string, unknown> | und
|
|
|
35
25
|
* 있습니다. 그래서 같은 길이면 전부를 비교합니다.
|
|
36
26
|
*/
|
|
37
27
|
export declare function secretMatches(given: string, expected: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* 인증 — **서명이 왔으면 서명을 보고, 아니면 비밀값을 본다.**
|
|
30
|
+
*
|
|
31
|
+
* 두 방식을 두는 이유는 밀어 주는 쪽의 사정이 다르기 때문이다. 이미 붙어 있는 커넥터는 비밀값을 헤더에
|
|
32
|
+
* 싣는 방식으로 만들어졌고, 새로 만드는 것(operato-mes)은 서명을 쓴다. 서명 쪽이 낫다 — 비밀값을 그대로
|
|
33
|
+
* 싣는 방식은 그 요청을 그대로 다시 보내는 것을 막지 못한다.
|
|
34
|
+
*
|
|
35
|
+
* **서명이 왔는데 확인할 수 없으면 거절한다.** 받은 바이트를 들고 있지 않으면(`rawBody` 가 없으면)
|
|
36
|
+
* 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다.
|
|
37
|
+
* 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다.
|
|
38
|
+
*/
|
|
39
|
+
export declare function authorizeHook(args: {
|
|
40
|
+
headers: Record<string, unknown> | undefined;
|
|
41
|
+
/** 받은 바이트 그대로. 서명을 쓰지 않는 연결에서는 없어도 된다. */
|
|
42
|
+
rawBody: string | undefined;
|
|
43
|
+
/** 연결 설정에 저장된 비밀값. 서명과 비밀값이 같은 값을 쓴다. */
|
|
44
|
+
secret: string;
|
|
45
|
+
nowMs: number;
|
|
46
|
+
}): {
|
|
47
|
+
ok: true;
|
|
48
|
+
method: 'signature' | 'secret';
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
reason: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* 커서에 적을 열쇠 — **트윈까지 넣는다.**
|
|
55
|
+
*
|
|
56
|
+
* ── 왜 (2026-08-31, MES 레인 질문에서 나옴) ───────────────────────────────
|
|
57
|
+
* 커서는 **연결마다** 한 행(`TwinReference.liveCursor`)에 산다. 그런데 연결 하나가 트윈 여럿을
|
|
58
|
+
* 만든다(`scopeSpec.produced` 가 목록이다). 보내는 쪽의 단위 이름만으로 열쇠를 잡으면, 같은 연결의
|
|
59
|
+
* 두 공장이 같은 이름(`EVENTS`)을 쓸 때 **한 계수기를 함께 쓴다.**
|
|
60
|
+
*
|
|
61
|
+
* 그러면 A 공장이 5까지 올린 뒤 B 공장이 1을 보내면 이미 본 번호가 되어 버려지고, A 가 6을 보내면
|
|
62
|
+
* B 의 번호와 어긋나 끊임없이 「빠졌다」가 된다. 같은 부류가 이 저장소에서 이미 났다 — 두 공장의
|
|
63
|
+
* 같은 번호가 한 자리에 겹쳤다.
|
|
64
|
+
*
|
|
65
|
+
* 보내는 쪽에 트윈 이름을 넣으라고 하지 않는다. 어디로 갈지는 주소가 이미 말했고, 그것을 본문에도
|
|
66
|
+
* 적으라고 하면 둘이 어긋나는 날 어느 쪽을 믿을지 정해야 한다.
|
|
67
|
+
*/
|
|
68
|
+
export declare function pushCursorKey(instanceId: string, scope: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* 커서에서 그 단위의 마지막 번호를 꺼낸다.
|
|
71
|
+
*
|
|
72
|
+
* 커서 모양은 `{ streams: { [흐름]: { since?, seen[], seq? } } }` 이고 **흐름 이름은 어댑터가 정한다**
|
|
73
|
+
* (§`TwinReference.liveCursor`). 밀어 주는 연결에서는 그 이름이 곧 번호를 매기는 단위다.
|
|
74
|
+
*
|
|
75
|
+
* 자리를 따로 만들지 않는 이유는 「재기동을 넘어 사는 진행 위치」가 두 벌이 되기 때문이다. 폴링이 쓰는
|
|
76
|
+
* `since` 와 밀어 주기가 쓰는 `seq` 는 둘 다 「이 흐름을 어디까지 읽었나」이고 원본이 되풀어 줄 수 있다.
|
|
77
|
+
*/
|
|
78
|
+
export declare function seqOf(cursor: unknown, scope: string): number | undefined;
|
|
79
|
+
/** 그 단위의 번호만 바꾼 커서를 만든다 — 다른 흐름과 `since`·`seen` 은 그대로 둔다. */
|
|
80
|
+
export declare function withSeq(cursor: unknown, scope: string, seq: number): Record<string, unknown>;
|
|
38
81
|
/** 이 연결이 이 트윈을 만들었나 — 남의 트윈에 밀어 넣지 못하게. */
|
|
39
82
|
export declare function producedInstance(ref: {
|
|
40
83
|
scopeSpec?: any;
|
|
@@ -1,32 +1,70 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.HOOK_STATUS = void 0;
|
|
4
3
|
exports.secretFromHeaders = secretFromHeaders;
|
|
5
4
|
exports.secretMatches = secretMatches;
|
|
5
|
+
exports.authorizeHook = authorizeHook;
|
|
6
|
+
exports.pushCursorKey = pushCursorKey;
|
|
7
|
+
exports.seqOf = seqOf;
|
|
8
|
+
exports.withSeq = withSeq;
|
|
6
9
|
exports.producedInstance = producedInstance;
|
|
10
|
+
/*
|
|
11
|
+
* 웹훅의 규약 — 상태 코드 · 비밀값 · 어느 트윈인가.
|
|
12
|
+
*
|
|
13
|
+
* 데이터베이스를 부르는 부분과 갈라 둔다(§`reference-hook`): 규약만 있는 자리라야 시험이 엔티티를
|
|
14
|
+
* 끌고 오지 않는다. 실제로 그것 때문에 시험이 서지 않았다.
|
|
15
|
+
*
|
|
16
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
17
|
+
* 지금 모든 커넥터가 주기적으로 물어봅니다(폴링). 그 방식은 두 가지를 치릅니다. 연결된 시스템이 값을
|
|
18
|
+
* 갱신하는 주기보다 자주 물으면 같은 값을 되풀이 받고, 그쪽 서버에 그만큼 부담을 줍니다. 실제로
|
|
19
|
+
* 태양광 발전소에 10초마다 물으면서 10분마다 바뀌는 값을 받아 왔습니다.
|
|
20
|
+
*
|
|
21
|
+
* 연결된 시스템이 「바뀌었다」를 말해 줄 수 있으면 그것을 받는 것이 낫습니다. 이 파일이 그 입구입니다.
|
|
22
|
+
*
|
|
23
|
+
* ── 규약 ────────────────────────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* 주소 POST /domain/<도메인>/twin/hook/<연결이름>/<트윈>
|
|
26
|
+
* 인증 연결 설정에 저장한 비밀값(`hookSecret`)을 헤더로 보낸다
|
|
27
|
+
* X-Twin-Hook-Secret: <비밀값> 또는 Authorization: Bearer <비밀값>
|
|
28
|
+
* 본문 연결된 시스템이 정한 모양 그대로. 우리 어휘로 옮기는 것은 커넥터의 일이다
|
|
29
|
+
* 응답 아래 §`hookStatus`
|
|
30
|
+
*
|
|
31
|
+
* ── 왜 사람 인증(JWT)이 아닌가 ──────────────────────────────────────────────
|
|
32
|
+
* 밀어 주는 쪽은 사람이 아닙니다. 우리 토큰을 발급받아 갱신하며 관리하라고 요구하면 대부분의 현장에서
|
|
33
|
+
* 연동이 서지 않습니다. 그래서 연결마다 비밀값을 두고 그것으로 확인합니다.
|
|
34
|
+
*
|
|
35
|
+
* **비밀값이 없는 연결은 훅을 받지 않습니다.** 기본이 거부입니다 — 인증 없는 입구를 열어 두는 것보다
|
|
36
|
+
* 훅을 못 쓰는 것이 낫습니다.
|
|
37
|
+
*
|
|
38
|
+
* ── 왜 커넥터가 옮기나 (시나리오가 아니라) ──────────────────────────────────
|
|
39
|
+
* 연결된 시스템의 웹훅은 그쪽이 정한 고정된 모양으로 옵니다. 그 모양을 아는 것은 그 커넥터이고,
|
|
40
|
+
* 옮기는 판단은 코드여야 테스트로 지킬 수 있습니다. 실제로 지금 커넥터들이 코드로 판단합니다 —
|
|
41
|
+
* 통신이 끊긴 설비의 값은 보내지 않고, 잰 시각이 나아가지 않은 줄은 거릅니다. 그 판단을 변환식으로
|
|
42
|
+
* 옮기면 약해지고 테스트가 없어집니다.
|
|
43
|
+
*
|
|
44
|
+
* ── 밀어 주기만으로는 안 된다 ───────────────────────────────────────────────
|
|
45
|
+
* 훅은 반드시 놓칩니다 — 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가 끊겼을 때. 그래서
|
|
46
|
+
* 훅을 쓰는 연결도 **드문 폴링을 유지합니다.** 폴링의 역할이 「값을 가져오는 것」에서 「놓친 것이 있나
|
|
47
|
+
* 확인하는 것」으로 바뀌는 것입니다. 그 주기는 연결 설정이 정합니다.
|
|
48
|
+
*
|
|
49
|
+
* ── 같은 것이 두 번 오는 것 ─────────────────────────────────────────────────
|
|
50
|
+
* 밀어 주는 방식은 재전송이 정상입니다(그쪽이 우리 응답을 못 받으면 다시 보냅니다). 그것은 유입
|
|
51
|
+
* 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지
|
|
52
|
+
* 함께 알립니다.
|
|
53
|
+
*/
|
|
54
|
+
const webhook_1 = require("@operato/ops-contract/webhook");
|
|
7
55
|
/**
|
|
8
|
-
* 응답 코드 규약 —
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* 500 우리 쪽 오류 재시도하면 될 수 있다
|
|
56
|
+
* 응답 코드 규약 — **계약이 정하고 여기서는 그것을 쓴다**(2026-08-31).
|
|
57
|
+
*
|
|
58
|
+
* 예전에는 이 표가 여기 있었고 보내는 쪽(operato-mes)에 또 있었다. 그래서 같은 409 를 양쪽이 다른
|
|
59
|
+
* 뜻으로 썼다 — 여기서는 「그 트윈이 실시간으로 돌지 않는다」, 저쪽에서는 「번호가 비었다」였다.
|
|
60
|
+
* 붙였다면 트윈이 안 도는 동안 보내는 쪽이 같은 구간을 계속 다시 보냈다.
|
|
61
|
+
*
|
|
62
|
+
* 판정표는 만드는 쪽과 읽는 쪽이 합의해야 하는 것이라 계약 층에 있다(`@operato/ops-contract`).
|
|
63
|
+
* `notLive` 가 409 에서 503 으로 옮겨진 이유도 그 파일에 적혀 있다.
|
|
64
|
+
*
|
|
65
|
+
* **여기서 이름을 바꿔 다시 내보내지 않는다.** 부르는 쪽은 `WEBHOOK_STATUS` 를 계약에서 바로 가져온다.
|
|
66
|
+
* 별명을 두면 같은 표에 이름이 둘이 되고, 다음 사람이 어느 쪽이 정본인지 물어야 한다.
|
|
20
67
|
*/
|
|
21
|
-
exports.HOOK_STATUS = {
|
|
22
|
-
ok: 200,
|
|
23
|
-
badSecret: 401,
|
|
24
|
-
unknownTarget: 404,
|
|
25
|
-
badPayload: 400,
|
|
26
|
-
notLive: 409,
|
|
27
|
-
unsupported: 501,
|
|
28
|
-
failed: 500
|
|
29
|
-
};
|
|
30
68
|
/** 헤더에서 비밀값을 꺼낸다 — 두 가지 방식을 받는다(밀어 주는 쪽의 관습이 갈린다). */
|
|
31
69
|
function secretFromHeaders(headers) {
|
|
32
70
|
const pick = (k) => String((headers ?? {})[k] ?? '').trim();
|
|
@@ -50,6 +88,80 @@ function secretMatches(given, expected) {
|
|
|
50
88
|
diff |= given.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
51
89
|
return diff === 0;
|
|
52
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* 인증 — **서명이 왔으면 서명을 보고, 아니면 비밀값을 본다.**
|
|
93
|
+
*
|
|
94
|
+
* 두 방식을 두는 이유는 밀어 주는 쪽의 사정이 다르기 때문이다. 이미 붙어 있는 커넥터는 비밀값을 헤더에
|
|
95
|
+
* 싣는 방식으로 만들어졌고, 새로 만드는 것(operato-mes)은 서명을 쓴다. 서명 쪽이 낫다 — 비밀값을 그대로
|
|
96
|
+
* 싣는 방식은 그 요청을 그대로 다시 보내는 것을 막지 못한다.
|
|
97
|
+
*
|
|
98
|
+
* **서명이 왔는데 확인할 수 없으면 거절한다.** 받은 바이트를 들고 있지 않으면(`rawBody` 가 없으면)
|
|
99
|
+
* 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다.
|
|
100
|
+
* 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다.
|
|
101
|
+
*/
|
|
102
|
+
function authorizeHook(args) {
|
|
103
|
+
const { headers, rawBody, secret, nowMs } = args;
|
|
104
|
+
const pick = (k) => String((headers ?? {})[k] ?? '').trim();
|
|
105
|
+
const signature = pick(webhook_1.WEBHOOK_HEADER.signature);
|
|
106
|
+
if (signature) {
|
|
107
|
+
if (rawBody === undefined) {
|
|
108
|
+
return { ok: false, reason: 'signed request but the raw body was not kept — cannot verify' };
|
|
109
|
+
}
|
|
110
|
+
const verdict = (0, webhook_1.verifyWebhookSignature)({
|
|
111
|
+
secret,
|
|
112
|
+
timestamp: pick(webhook_1.WEBHOOK_HEADER.timestamp),
|
|
113
|
+
signature,
|
|
114
|
+
body: rawBody,
|
|
115
|
+
nowMs
|
|
116
|
+
});
|
|
117
|
+
/* `=== false` 로 본다 — 이 저장소는 `strictNullChecks` 가 꺼져 있어 참·거짓만으로는 좁혀지지 않는다. */
|
|
118
|
+
if (verdict.ok === false)
|
|
119
|
+
return { ok: false, reason: `signature ${verdict.reason}` };
|
|
120
|
+
return { ok: true, method: 'signature' };
|
|
121
|
+
}
|
|
122
|
+
return secretMatches(secretFromHeaders(headers), secret)
|
|
123
|
+
? { ok: true, method: 'secret' }
|
|
124
|
+
: { ok: false, reason: 'hook secret does not match' };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 커서에 적을 열쇠 — **트윈까지 넣는다.**
|
|
128
|
+
*
|
|
129
|
+
* ── 왜 (2026-08-31, MES 레인 질문에서 나옴) ───────────────────────────────
|
|
130
|
+
* 커서는 **연결마다** 한 행(`TwinReference.liveCursor`)에 산다. 그런데 연결 하나가 트윈 여럿을
|
|
131
|
+
* 만든다(`scopeSpec.produced` 가 목록이다). 보내는 쪽의 단위 이름만으로 열쇠를 잡으면, 같은 연결의
|
|
132
|
+
* 두 공장이 같은 이름(`EVENTS`)을 쓸 때 **한 계수기를 함께 쓴다.**
|
|
133
|
+
*
|
|
134
|
+
* 그러면 A 공장이 5까지 올린 뒤 B 공장이 1을 보내면 이미 본 번호가 되어 버려지고, A 가 6을 보내면
|
|
135
|
+
* B 의 번호와 어긋나 끊임없이 「빠졌다」가 된다. 같은 부류가 이 저장소에서 이미 났다 — 두 공장의
|
|
136
|
+
* 같은 번호가 한 자리에 겹쳤다.
|
|
137
|
+
*
|
|
138
|
+
* 보내는 쪽에 트윈 이름을 넣으라고 하지 않는다. 어디로 갈지는 주소가 이미 말했고, 그것을 본문에도
|
|
139
|
+
* 적으라고 하면 둘이 어긋나는 날 어느 쪽을 믿을지 정해야 한다.
|
|
140
|
+
*/
|
|
141
|
+
function pushCursorKey(instanceId, scope) {
|
|
142
|
+
return `${instanceId}::${scope}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 커서에서 그 단위의 마지막 번호를 꺼낸다.
|
|
146
|
+
*
|
|
147
|
+
* 커서 모양은 `{ streams: { [흐름]: { since?, seen[], seq? } } }` 이고 **흐름 이름은 어댑터가 정한다**
|
|
148
|
+
* (§`TwinReference.liveCursor`). 밀어 주는 연결에서는 그 이름이 곧 번호를 매기는 단위다.
|
|
149
|
+
*
|
|
150
|
+
* 자리를 따로 만들지 않는 이유는 「재기동을 넘어 사는 진행 위치」가 두 벌이 되기 때문이다. 폴링이 쓰는
|
|
151
|
+
* `since` 와 밀어 주기가 쓰는 `seq` 는 둘 다 「이 흐름을 어디까지 읽었나」이고 원본이 되풀어 줄 수 있다.
|
|
152
|
+
*/
|
|
153
|
+
function seqOf(cursor, scope) {
|
|
154
|
+
const streams = cursor?.streams;
|
|
155
|
+
const seq = streams?.[scope]?.seq;
|
|
156
|
+
return typeof seq === 'number' && Number.isInteger(seq) ? seq : undefined;
|
|
157
|
+
}
|
|
158
|
+
/** 그 단위의 번호만 바꾼 커서를 만든다 — 다른 흐름과 `since`·`seen` 은 그대로 둔다. */
|
|
159
|
+
function withSeq(cursor, scope, seq) {
|
|
160
|
+
const base = (cursor && typeof cursor === 'object' ? cursor : {});
|
|
161
|
+
const streams = base.streams && typeof base.streams === 'object' ? { ...base.streams } : {};
|
|
162
|
+
streams[scope] = { ...(streams[scope] ?? {}), seq };
|
|
163
|
+
return { ...base, streams };
|
|
164
|
+
}
|
|
53
165
|
/** 이 연결이 이 트윈을 만들었나 — 남의 트윈에 밀어 넣지 못하게. */
|
|
54
166
|
function producedInstance(ref, instanceId) {
|
|
55
167
|
const produced = ref?.scopeSpec?.produced ?? [];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-contract.js","sourceRoot":"","sources":["../../../server/service/reference/hook-contract.ts"],"names":[],"mappings":";;;AA2EA,8CAMC;AAQD,sCAKC;AAGD,4CAIC;AAnDD;;;;;;;;;;;;;GAaG;AACU,QAAA,WAAW,GAAG;IACzB,EAAE,EAAE,GAAG;IACP,SAAS,EAAE,GAAG;IACd,aAAa,EAAE,GAAG;IAClB,UAAU,EAAE,GAAG;IACf,OAAO,EAAE,GAAG;IACZ,WAAW,EAAE,GAAG;IAChB,MAAM,EAAE,GAAG;CACH,CAAA;AAEV,sDAAsD;AACtD,SAAgB,iBAAiB,CAAC,OAA4C;IAC5E,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IACnE,MAAM,MAAM,GAAG,IAAI,CAAC,oBAAoB,CAAC,CAAA;IACzC,IAAI,MAAM;QAAE,OAAO,MAAM,CAAA;IACzB,MAAM,IAAI,GAAG,IAAI,CAAC,eAAe,CAAC,CAAA;IAClC,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;AAC7E,CAAC;AAED;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,KAAa,EAAE,QAAgB;IAC3D,IAAI,CAAC,QAAQ,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IACzE,IAAI,IAAI,GAAG,CAAC,CAAA;IACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE;QAAE,IAAI,IAAI,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;IAC9F,OAAO,IAAI,KAAK,CAAC,CAAA;AACnB,CAAC;AAED,2CAA2C;AAC3C,SAAgB,gBAAgB,CAAC,GAA2C,EAAE,UAAkB;IAC9F,MAAM,QAAQ,GAAW,GAAG,EAAE,SAAiB,EAAE,QAAQ,IAAI,EAAE,CAAA;IAC/D,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,EAAE,UAAU,IAAI,EAAE,CAAC,KAAK,UAAU,CAAC,CAAA;IAC1E,OAAO,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;AACjE,CAAC","sourcesContent":["/*\n * 웹훅의 규약 — 상태 코드 · 비밀값 · 어느 트윈인가.\n *\n * 데이터베이스를 부르는 부분과 갈라 둔다(§`reference-hook`): 규약만 있는 자리라야 시험이 엔티티를\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 */\n/** 훅 요청의 결과 — 상태 코드와 본문을 함께 정한다(부르는 쪽이 그대로 답한다). */\nexport interface HookOutcome {\n status: number\n body: Record<string, unknown>\n}\n\n/**\n * 응답 코드 규약 — **재시도로 해결되는 것과 그렇지 않은 것을 가른다.**\n *\n * 밀어 주는 쪽은 응답 코드로 재시도를 정합니다. 그래서 우리가 코드를 아무렇게나 주면 두 가지가\n * 일어납니다: 고칠 수 없는 것을 영원히 다시 보내거나, 잠깐의 문제로 사라진 사실을 아무도 모릅니다.\n *\n * 200 받아서 반영했다\n * 401 비밀값이 다르다 재시도해도 같다\n * 404 모르는 연결이거나 모르는 트윈 재시도해도 같다\n * 400 본문을 우리 레코드로 옮길 수 없다 재시도해도 같다(그쪽 모양이 바뀐 것이다)\n * 409 그 트윈이 지금 실시간으로 돌지 않는다 사람이 트윈을 띄워야 한다\n * 501 그 커넥터가 훅을 받을 줄 모른다 코드가 없다\n * 500 우리 쪽 오류 재시도하면 될 수 있다\n */\nexport const HOOK_STATUS = {\n ok: 200,\n badSecret: 401,\n unknownTarget: 404,\n badPayload: 400,\n notLive: 409,\n unsupported: 501,\n failed: 500\n} as const\n\n/** 헤더에서 비밀값을 꺼낸다 — 두 가지 방식을 받는다(밀어 주는 쪽의 관습이 갈린다). */\nexport function secretFromHeaders(headers: Record<string, unknown> | undefined): string {\n const pick = (k: string) => String((headers ?? {})[k] ?? '').trim()\n const direct = pick('x-twin-hook-secret')\n if (direct) return direct\n const auth = pick('authorization')\n return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''\n}\n\n/**\n * 비밀값 비교 — **길이가 다르면 바로 거절하고, 같으면 끝까지 비교한다.**\n *\n * 앞에서 다른 것을 발견하고 곧바로 답하면 응답 시간이 달라지고, 그 차이로 비밀값을 한 글자씩 알아낼 수\n * 있습니다. 그래서 같은 길이면 전부를 비교합니다.\n */\nexport function secretMatches(given: string, expected: string): boolean {\n if (!expected || !given || given.length !== expected.length) return false\n let diff = 0\n for (let i = 0; i < expected.length; i++) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i)\n return diff === 0\n}\n\n/** 이 연결이 이 트윈을 만들었나 — 남의 트윈에 밀어 넣지 못하게. */\nexport function producedInstance(ref: { scopeSpec?: any } | null | undefined, instanceId: string): { siteId: string } | undefined {\n const produced: any[] = (ref?.scopeSpec as any)?.produced ?? []\n const hit = produced.find(p => String(p?.instanceId ?? '') === instanceId)\n return hit?.siteId ? { siteId: String(hit.siteId) } : undefined\n}\n\n"]}
|
|
1
|
+
{"version":3,"file":"hook-contract.js","sourceRoot":"","sources":["../../../server/service/reference/hook-contract.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 웹훅의 규약 — 상태 코드 · 비밀값 · 어느 트윈인가.\n *\n * 데이터베이스를 부르는 부분과 갈라 둔다(§`reference-hook`): 규약만 있는 자리라야 시험이 엔티티를\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_HEADER, verifyWebhookSignature } from '@operato/ops-contract/webhook'\n\n/** 훅 요청의 결과 — 상태 코드와 본문을 함께 정한다(부르는 쪽이 그대로 답한다). */\nexport interface HookOutcome {\n status: number\n body: Record<string, unknown>\n}\n\n/**\n * 응답 코드 규약 — **계약이 정하고 여기서는 그것을 쓴다**(2026-08-31).\n *\n * 예전에는 이 표가 여기 있었고 보내는 쪽(operato-mes)에 또 있었다. 그래서 같은 409 를 양쪽이 다른\n * 뜻으로 썼다 — 여기서는 「그 트윈이 실시간으로 돌지 않는다」, 저쪽에서는 「번호가 비었다」였다.\n * 붙였다면 트윈이 안 도는 동안 보내는 쪽이 같은 구간을 계속 다시 보냈다.\n *\n * 판정표는 만드는 쪽과 읽는 쪽이 합의해야 하는 것이라 계약 층에 있다(`@operato/ops-contract`).\n * `notLive` 가 409 에서 503 으로 옮겨진 이유도 그 파일에 적혀 있다.\n *\n * **여기서 이름을 바꿔 다시 내보내지 않는다.** 부르는 쪽은 `WEBHOOK_STATUS` 를 계약에서 바로 가져온다.\n * 별명을 두면 같은 표에 이름이 둘이 되고, 다음 사람이 어느 쪽이 정본인지 물어야 한다.\n */\n\n/** 헤더에서 비밀값을 꺼낸다 — 두 가지 방식을 받는다(밀어 주는 쪽의 관습이 갈린다). */\nexport function secretFromHeaders(headers: Record<string, unknown> | undefined): string {\n const pick = (k: string) => String((headers ?? {})[k] ?? '').trim()\n const direct = pick('x-twin-hook-secret')\n if (direct) return direct\n const auth = pick('authorization')\n return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''\n}\n\n/**\n * 비밀값 비교 — **길이가 다르면 바로 거절하고, 같으면 끝까지 비교한다.**\n *\n * 앞에서 다른 것을 발견하고 곧바로 답하면 응답 시간이 달라지고, 그 차이로 비밀값을 한 글자씩 알아낼 수\n * 있습니다. 그래서 같은 길이면 전부를 비교합니다.\n */\nexport function secretMatches(given: string, expected: string): boolean {\n if (!expected || !given || given.length !== expected.length) return false\n let diff = 0\n for (let i = 0; i < expected.length; i++) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i)\n return diff === 0\n}\n\n/**\n * 인증 — **서명이 왔으면 서명을 보고, 아니면 비밀값을 본다.**\n *\n * 두 방식을 두는 이유는 밀어 주는 쪽의 사정이 다르기 때문이다. 이미 붙어 있는 커넥터는 비밀값을 헤더에\n * 싣는 방식으로 만들어졌고, 새로 만드는 것(operato-mes)은 서명을 쓴다. 서명 쪽이 낫다 — 비밀값을 그대로\n * 싣는 방식은 그 요청을 그대로 다시 보내는 것을 막지 못한다.\n *\n * **서명이 왔는데 확인할 수 없으면 거절한다.** 받은 바이트를 들고 있지 않으면(`rawBody` 가 없으면)\n * 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다.\n * 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다.\n */\nexport function authorizeHook(args: {\n headers: Record<string, unknown> | undefined\n /** 받은 바이트 그대로. 서명을 쓰지 않는 연결에서는 없어도 된다. */\n rawBody: string | undefined\n /** 연결 설정에 저장된 비밀값. 서명과 비밀값이 같은 값을 쓴다. */\n secret: string\n nowMs: number\n}): { ok: true; method: 'signature' | 'secret' } | { ok: false; reason: string } {\n const { headers, rawBody, secret, nowMs } = args\n const pick = (k: string) => String((headers ?? {})[k] ?? '').trim()\n const signature = pick(WEBHOOK_HEADER.signature)\n\n if (signature) {\n if (rawBody === undefined) {\n return { ok: false, reason: 'signed request but the raw body was not kept — cannot verify' }\n }\n const verdict = verifyWebhookSignature({\n secret,\n timestamp: pick(WEBHOOK_HEADER.timestamp),\n signature,\n body: rawBody,\n nowMs\n })\n /* `=== false` 로 본다 — 이 저장소는 `strictNullChecks` 가 꺼져 있어 참·거짓만으로는 좁혀지지 않는다. */\n if (verdict.ok === false) return { ok: false, reason: `signature ${verdict.reason}` }\n return { ok: true, method: 'signature' }\n }\n\n return secretMatches(secretFromHeaders(headers), secret)\n ? { ok: true, method: 'secret' }\n : { ok: false, reason: 'hook secret does not match' }\n}\n\n/**\n * 커서에 적을 열쇠 — **트윈까지 넣는다.**\n *\n * ── 왜 (2026-08-31, MES 레인 질문에서 나옴) ───────────────────────────────\n * 커서는 **연결마다** 한 행(`TwinReference.liveCursor`)에 산다. 그런데 연결 하나가 트윈 여럿을\n * 만든다(`scopeSpec.produced` 가 목록이다). 보내는 쪽의 단위 이름만으로 열쇠를 잡으면, 같은 연결의\n * 두 공장이 같은 이름(`EVENTS`)을 쓸 때 **한 계수기를 함께 쓴다.**\n *\n * 그러면 A 공장이 5까지 올린 뒤 B 공장이 1을 보내면 이미 본 번호가 되어 버려지고, A 가 6을 보내면\n * B 의 번호와 어긋나 끊임없이 「빠졌다」가 된다. 같은 부류가 이 저장소에서 이미 났다 — 두 공장의\n * 같은 번호가 한 자리에 겹쳤다.\n *\n * 보내는 쪽에 트윈 이름을 넣으라고 하지 않는다. 어디로 갈지는 주소가 이미 말했고, 그것을 본문에도\n * 적으라고 하면 둘이 어긋나는 날 어느 쪽을 믿을지 정해야 한다.\n */\nexport function pushCursorKey(instanceId: string, scope: string): string {\n return `${instanceId}::${scope}`\n}\n\n/**\n * 커서에서 그 단위의 마지막 번호를 꺼낸다.\n *\n * 커서 모양은 `{ streams: { [흐름]: { since?, seen[], seq? } } }` 이고 **흐름 이름은 어댑터가 정한다**\n * (§`TwinReference.liveCursor`). 밀어 주는 연결에서는 그 이름이 곧 번호를 매기는 단위다.\n *\n * 자리를 따로 만들지 않는 이유는 「재기동을 넘어 사는 진행 위치」가 두 벌이 되기 때문이다. 폴링이 쓰는\n * `since` 와 밀어 주기가 쓰는 `seq` 는 둘 다 「이 흐름을 어디까지 읽었나」이고 원본이 되풀어 줄 수 있다.\n */\nexport function seqOf(cursor: unknown, scope: string): number | undefined {\n const streams = (cursor as any)?.streams\n const seq = streams?.[scope]?.seq\n return typeof seq === 'number' && Number.isInteger(seq) ? seq : undefined\n}\n\n/** 그 단위의 번호만 바꾼 커서를 만든다 — 다른 흐름과 `since`·`seen` 은 그대로 둔다. */\nexport function withSeq(cursor: unknown, scope: string, seq: number): Record<string, unknown> {\n const base = (cursor && typeof cursor === 'object' ? (cursor as Record<string, unknown>) : {}) as Record<string, any>\n const streams = base.streams && typeof base.streams === 'object' ? { ...base.streams } : {}\n streams[scope] = { ...(streams[scope] ?? {}), seq }\n return { ...base, streams }\n}\n\n/** 이 연결이 이 트윈을 만들었나 — 남의 트윈에 밀어 넣지 못하게. */\nexport function producedInstance(ref: { scopeSpec?: any } | null | undefined, instanceId: string): { siteId: string } | undefined {\n const produced: any[] = (ref?.scopeSpec as any)?.produced ?? []\n const hit = produced.find(p => String(p?.instanceId ?? '') === instanceId)\n return hit?.siteId ? { siteId: String(hit.siteId) } : undefined\n}\n\n"]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.databaseStore = databaseStore;
|
|
4
|
+
/*
|
|
5
|
+
* 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것.
|
|
6
|
+
*
|
|
7
|
+
* ── 왜 갈라져 있나 (2026-08-31) ──────────────────────────────────────────
|
|
8
|
+
* `reference-hook.ts` 가 엔티티를 직접 가져오면 그 파일을 부르는 시험이 TypeORM 데코레이터를 지나게
|
|
9
|
+
* 되고, 시험이 서지 않는다. 실제로 훅 시험이 규약 파일(`hook-contract.ts`)만 부르고 있었던 이유가
|
|
10
|
+
* 그것이다 — 그래서 **배선은 아무 시험도 걷지 않았다.**
|
|
11
|
+
*
|
|
12
|
+
* 저장소에 닿는 두 함수만 여기로 내면, 처리 순서 전체를 시험이 같은 함수로 걸을 수 있다. 시험은
|
|
13
|
+
* 저장본을 손에 들고 있는 구현을 넣어 재기동까지 넣는다.
|
|
14
|
+
*/
|
|
15
|
+
const shell_1 = require("@things-factory/shell");
|
|
16
|
+
const twin_reference_js_1 = require("./twin-reference.js");
|
|
17
|
+
/** 실제 저장소를 쓰는 구현 — 라우터가 이것을 넣는다. */
|
|
18
|
+
function databaseStore() {
|
|
19
|
+
return {
|
|
20
|
+
load: (domainId, source) => (0, shell_1.getRepository)(twin_reference_js_1.TwinReference)
|
|
21
|
+
.findOne({ where: { domain: { id: domainId }, source } })
|
|
22
|
+
.catch(() => null),
|
|
23
|
+
saveCursor: async (refId, cursor) => {
|
|
24
|
+
await (0, shell_1.getRepository)(twin_reference_js_1.TwinReference).update({ id: refId }, { liveCursor: cursor });
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=hook-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-store.js","sourceRoot":"","sources":["../../../server/service/reference/hook-store.ts"],"names":[],"mappings":";;AAiBA,sCAUC;AA3BD;;;;;;;;;;GAUG;AACH,iDAAqD;AAErD,2DAAmD;AAGnD,oCAAoC;AACpC,SAAgB,aAAa;IAC3B,OAAO;QACL,IAAI,EAAE,CAAC,QAAgB,EAAE,MAAc,EAAE,EAAE,CACzC,IAAA,qBAAa,EAAC,iCAAa,CAAC;aACzB,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC;aACxD,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC;QACtB,UAAU,EAAE,KAAK,EAAE,KAAa,EAAE,MAAe,EAAE,EAAE;YACnD,MAAM,IAAA,qBAAa,EAAC,iCAAa,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,UAAU,EAAE,MAAM,EAAS,CAAC,CAAA;QACzF,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["/*\n * 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것.\n *\n * ── 왜 갈라져 있나 (2026-08-31) ──────────────────────────────────────────\n * `reference-hook.ts` 가 엔티티를 직접 가져오면 그 파일을 부르는 시험이 TypeORM 데코레이터를 지나게\n * 되고, 시험이 서지 않는다. 실제로 훅 시험이 규약 파일(`hook-contract.ts`)만 부르고 있었던 이유가\n * 그것이다 — 그래서 **배선은 아무 시험도 걷지 않았다.**\n *\n * 저장소에 닿는 두 함수만 여기로 내면, 처리 순서 전체를 시험이 같은 함수로 걸을 수 있다. 시험은\n * 저장본을 손에 들고 있는 구현을 넣어 재기동까지 넣는다.\n */\nimport { getRepository } from '@things-factory/shell'\n\nimport { TwinReference } from './twin-reference.js'\nimport type { HookStore } from './reference-hook.js'\n\n/** 실제 저장소를 쓰는 구현 — 라우터가 이것을 넣는다. */\nexport function databaseStore(): HookStore {\n return {\n load: (domainId: string, source: string) =>\n getRepository(TwinReference)\n .findOne({ where: { domain: { id: domainId }, source } })\n .catch(() => null),\n saveCursor: async (refId: string, cursor: unknown) => {\n await getRepository(TwinReference).update({ id: refId }, { liveCursor: cursor } as any)\n }\n }\n}\n"]}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ApprovedCommand } from '@operato/ops-contract';
|
|
1
2
|
import type { ReferenceStep } from './reference-progress.js';
|
|
2
3
|
import type { ReferenceMaster } from './reference-master.js';
|
|
3
4
|
/** 연결 설정(어댑터별 스키마) — 엔드포인트·자격·파라미터. */
|
|
@@ -124,6 +125,41 @@ export interface ObservedItemFact {
|
|
|
124
125
|
expiry?: number;
|
|
125
126
|
lot?: string;
|
|
126
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* 밀어 받은 본문 하나를 옮긴 결과 (2026-08-31).
|
|
130
|
+
*
|
|
131
|
+
* 레코드만 돌려주던 것을 넓혔다. 번호를 함께 받아야 프레임워크가 빠진 것을 알아챈다.
|
|
132
|
+
*
|
|
133
|
+
* **`scope` 가 없으면 `seq` 도 없는 것으로 다룬다.** 번호만 있고 그 번호가 어느 줄의 것인지 모르면
|
|
134
|
+
* 커서를 무엇으로 잡을지 짐작해야 하고, 짐작한 열쇠는 보내는 쪽이 줄을 하나 더 늘리는 날 어긋난다.
|
|
135
|
+
* 실제로 operato-mes 가 `[domain, channel]` 마다 번호를 매기면서 봉투에 `channel` 을 싣지 않고 있었다.
|
|
136
|
+
*/
|
|
137
|
+
export interface InboundBatch {
|
|
138
|
+
/**
|
|
139
|
+
* 옮긴 것들. 우리와 무관한 본문이면 빈 배열이다.
|
|
140
|
+
*
|
|
141
|
+
* **한 요청에 봉투가 여럿 온다.** 밀어 주는 쪽은 한 건씩 보내지 않는다 — 번호도 봉투마다 붙는다.
|
|
142
|
+
* 배치를 대표하는 번호 하나로는 가운데가 빠진 것을 알아채지 못한다.
|
|
143
|
+
*/
|
|
144
|
+
items: InboundItem[];
|
|
145
|
+
/**
|
|
146
|
+
* 이 요청의 번호가 어느 줄의 것인가. 번호를 매기지 않는 원본은 주지 않는다.
|
|
147
|
+
*
|
|
148
|
+
* 요청 하나가 한 줄에만 속한다 — 한 요청에 두 줄을 섞으면 어느 줄의 어디까지 받았는지를 응답
|
|
149
|
+
* 하나로 말할 수 없다.
|
|
150
|
+
*/
|
|
151
|
+
scope?: string;
|
|
152
|
+
}
|
|
153
|
+
export interface InboundItem {
|
|
154
|
+
/** 우리 어휘로 옮긴 레코드 하나. */
|
|
155
|
+
record: unknown;
|
|
156
|
+
/**
|
|
157
|
+
* 이 봉투의 번호.
|
|
158
|
+
*
|
|
159
|
+
* **한 배치 안에서 있거나 없거나 하나로 통일한다.** 섞이면 빠진 것이 있는지 판단할 근거가 없다.
|
|
160
|
+
*/
|
|
161
|
+
seq?: number;
|
|
162
|
+
}
|
|
127
163
|
/**
|
|
128
164
|
* 재기동을 넘어 이어지는 읽기 상태 — 참조 계층이 소유하고 어댑터에 건넨다(§`openLiveFeed`).
|
|
129
165
|
*
|
|
@@ -344,10 +380,38 @@ export interface ReferenceAdapter {
|
|
|
344
380
|
* ── 판단은 여기서 한다 ────────────────────────────────────────────────────
|
|
345
381
|
* 통신이 끊긴 설비의 값을 보내지 않는 것, 잰 시각이 나아가지 않은 줄을 거르는 것 — 폴링에서 하던
|
|
346
382
|
* 판단을 여기서도 한다. 그래야 두 길이 같은 답을 낸다.
|
|
383
|
+
*
|
|
384
|
+
* ── 번호는 프레임워크가 본다 (2026-08-31) ─────────────────────────────────
|
|
385
|
+
* 빠진 번호를 알아채는 일은 밀어 주는 모든 연결에 같은 규율이라야 한다. 커넥터마다 만들면 한 곳이
|
|
386
|
+
* 빠지고, 빠진 그 연결에서만 사실이 없어진다. 그래서 커넥터는 **번호를 꺼내 주기만** 하고 판정은
|
|
387
|
+
* 프레임워크가 한다(§`reference-hook`).
|
|
347
388
|
*/
|
|
348
389
|
handleInbound?(cfg: ConnectionConfig, site: {
|
|
349
390
|
siteId: string;
|
|
350
|
-
}, body: unknown, headers: Record<string, unknown>):
|
|
391
|
+
}, body: unknown, headers: Record<string, unknown>): InboundBatch;
|
|
392
|
+
/**
|
|
393
|
+
* **현장에 조치를 내린다** — 인바운드의 반대 면(§`command-routing.md` §8.1).
|
|
394
|
+
*
|
|
395
|
+
* 별개 어댑터를 두지 않는다. 같은 연결이 사실을 읽고 조치를 내리므로, 인증과 설정이 한 자리에 있어야
|
|
396
|
+
* 한다. 둘로 나누면 같은 시스템에 두 벌의 연결 설정이 생긴다.
|
|
397
|
+
*
|
|
398
|
+
* ── 승인은 이미 지났다 (2026-08-31) ───────────────────────────────────────
|
|
399
|
+
* 인자가 `ApprovedCommand` 다 — 그 표식은 승인을 지난 커맨드에만 붙고, 디스패처가 붙인다
|
|
400
|
+
* (§`command-dispatcher.ts`). **어댑터는 승인을 다시 확인하지 않는다.** 확인이 두 곳에 있으면
|
|
401
|
+
* 규칙이 두 벌이 되고 한쪽만 고쳐지는 날이 온다.
|
|
402
|
+
*
|
|
403
|
+
* 이 자리를 게이트보다 먼저 열지 않은 이유가 있다 — 자리가 있으면 누군가 부르고, 그때 사람 승인 없이
|
|
404
|
+
* 현장에 작업지시가 나간다. 작업지시는 취소해도 이미 만든 것이 남는다.
|
|
405
|
+
*
|
|
406
|
+
* ── 돌려줄 것 ─────────────────────────────────────────────────────────────
|
|
407
|
+
* `ref` 를 **반드시** 돌려준다. 그것이 없으면 넘긴 것이 저쪽에서 무엇이 되었는지 되짚을 수 없고,
|
|
408
|
+
* 되돌릴 때 무엇을 되돌릴지 말할 수 없다.
|
|
409
|
+
*/
|
|
410
|
+
actuate?(cfg: ConnectionConfig, site: SiteDescriptor, command: ApprovedCommand): Promise<{
|
|
411
|
+
ok: boolean;
|
|
412
|
+
ref?: string;
|
|
413
|
+
error?: string;
|
|
414
|
+
}>;
|
|
351
415
|
openLiveFeed?(cfg: ConnectionConfig, site: SiteDescriptor, onRecords: (records: unknown[]) => void, continuity?: LiveFeedContinuity): () => void;
|
|
352
416
|
/**
|
|
353
417
|
* 이 원본이 **선언하는 능력** — 없으면 「구조만 준다」는 뜻이다(ADR-0029 §8).
|