@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.
Files changed (43) hide show
  1. package/dist-server/routes.js +12 -1
  2. package/dist-server/routes.js.map +1 -1
  3. package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
  4. package/dist-server/service/actuation/command-dispatcher.js +92 -0
  5. package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
  6. package/dist-server/service/actuation/command-store.d.ts +3 -0
  7. package/dist-server/service/actuation/command-store.js +56 -0
  8. package/dist-server/service/actuation/command-store.js.map +1 -0
  9. package/dist-server/service/actuation/index.d.ts +6 -0
  10. package/dist-server/service/actuation/index.js +11 -0
  11. package/dist-server/service/actuation/index.js.map +1 -0
  12. package/dist-server/service/actuation/twin-command.d.ts +30 -0
  13. package/dist-server/service/actuation/twin-command.js +110 -0
  14. package/dist-server/service/actuation/twin-command.js.map +1 -0
  15. package/dist-server/service/index.d.ts +1 -1
  16. package/dist-server/service/index.js +20 -17
  17. package/dist-server/service/index.js.map +1 -1
  18. package/dist-server/service/reference/hook-contract.d.ts +64 -21
  19. package/dist-server/service/reference/hook-contract.js +134 -22
  20. package/dist-server/service/reference/hook-contract.js.map +1 -1
  21. package/dist-server/service/reference/hook-store.d.ts +3 -0
  22. package/dist-server/service/reference/hook-store.js +28 -0
  23. package/dist-server/service/reference/hook-store.js.map +1 -0
  24. package/dist-server/service/reference/reference-adapter.d.ts +65 -1
  25. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  26. package/dist-server/service/reference/reference-hook.d.ts +42 -6
  27. package/dist-server/service/reference/reference-hook.js +185 -27
  28. package/dist-server/service/reference/reference-hook.js.map +1 -1
  29. package/package.json +7 -7
  30. package/server/routes.ts +12 -1
  31. package/server/service/actuation/command-dispatcher.ts +130 -0
  32. package/server/service/actuation/command-store.ts +62 -0
  33. package/server/service/actuation/index.ts +8 -0
  34. package/server/service/actuation/twin-command.ts +105 -0
  35. package/server/service/index.ts +3 -0
  36. package/server/service/reference/hook-contract.ts +99 -21
  37. package/server/service/reference/hook-store.ts +28 -0
  38. package/server/service/reference/reference-adapter.ts +67 -2
  39. package/server/service/reference/reference-hook.ts +249 -32
  40. package/test/actuation-dispatch.test.ts +196 -0
  41. package/test/hook-sequence.test.ts +333 -0
  42. package/test/reference-hook.test.ts +114 -23
  43. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.0.18",
3
+ "version": "10.0.19",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh <heartyoh@hatiolab.com>",
@@ -27,12 +27,12 @@
27
27
  "clean:shared": "rm -rf dist-shared tsconfig.shared.tsbuildinfo"
28
28
  },
29
29
  "dependencies": {
30
- "@operato/ops-contract": "^0.6.0",
31
- "@operato/twin-kernel": "^0.9.0",
32
- "@things-factory/auth-base": "^10.0.18",
33
- "@things-factory/cache-service": "^10.0.18",
30
+ "@operato/ops-contract": "^0.8.1",
31
+ "@operato/twin-kernel": "^0.10.1",
32
+ "@things-factory/auth-base": "^10.0.19",
33
+ "@things-factory/cache-service": "^10.0.19",
34
34
  "@things-factory/env": "^10.0.8",
35
- "@things-factory/shell": "^10.0.18"
35
+ "@things-factory/shell": "^10.0.19"
36
36
  },
37
- "gitHead": "806c7af400517177f1323645df03558451587533"
37
+ "gitHead": "5c170a55152cfe906fe6dbc30e6bfe474ed64122"
38
38
  }
package/server/routes.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  import { ingestCanonicalRecords } from './engine/canonical-ingest.js'
19
19
  import { TwinEngine } from './engine/twin-engine.js'
20
20
  import { handleHook } from './service/reference/reference-hook.js'
21
+ import { databaseStore } from './service/reference/hook-store.js'
21
22
 
22
23
  process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: any) => {
23
24
  /*
@@ -39,6 +40,12 @@ process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: an
39
40
  instanceId: String(ctx.params.instanceId ?? ''),
40
41
  headers: (ctx.request?.headers ?? {}) as Record<string, unknown>,
41
42
  body: ctx.request?.body,
43
+ /*
44
+ * 받은 바이트 그대로 — 서명을 확인하려면 이것이 있어야 한다. `koa-bodyparser` 가 파싱한 본문과
45
+ * 함께 남겨 준다. 파싱본을 다시 문자열로 만들면 키 순서와 공백이 달라져 서명이 맞지 않는다.
46
+ */
47
+ rawBody: typeof ctx.request?.rawBody === 'string' ? ctx.request.rawBody : undefined,
48
+ store: databaseStore(),
42
49
  /*
43
50
  * 폴링이 쓰는 길을 그대로 쓴다 — 검증도 유입도 같은 자리다. 그래서 트윈 쪽에서는 물어서 받은
44
51
  * 것과 밀어 받은 것이 구별되지 않는다. 구별되는 것은 유입 장부의 `sources` 한 줄뿐이다.
@@ -59,7 +66,11 @@ process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: an
59
66
  */
60
67
  const undelivered = Math.max(0, accepted.length - applied - duplicates)
61
68
  TwinEngine.recordIngestResult(domain.id, instanceId, records.length, rejected, Date.now(), undelivered)
62
- return { applied, duplicates, rejected: rejected.length }
69
+ /*
70
+ * 떨어진 것을 **수가 아니라 목록으로** 넘긴다 — 응답에 실어야 보내는 쪽이 그것만 따로 둘 수
71
+ * 있다. 수만 주면 배치 전체를 다시 보내거나 전부 버리는 두 갈래밖에 없다.
72
+ */
73
+ return { applied, duplicates, rejected }
63
74
  }
64
75
  })
65
76
  ctx.status = outcome.status
@@ -0,0 +1,130 @@
1
+ /*
2
+ * **승인을 지난 커맨드만 어댑터에 넘긴다** — 게이트가 서는 자리.
3
+ *
4
+ * ── 왜 이 자리인가 (2026-08-31) ──────────────────────────────────────────
5
+ * 설계가 「기본 = propose-only」를 정해 두었는데 코드에 그 규율이 없었다. 인티그레이션 레인이 어댑터에
6
+ * 조치를 붙이려다 그것을 보고 멈췄다 — 게이트 없이 붙이면 트윈이 사람 승인 없이 현장에 작업지시를
7
+ * 내린다. 작업지시는 취소해도 이미 만든 것이 남는다.
8
+ *
9
+ * ── 확인을 여기서 한 번만 한다 ───────────────────────────────────────────
10
+ * 어댑터는 승인을 확인하지 않는다. 그 확인은 이 파일이 했고 **타입이 그것을 증명한다**
11
+ * (`ApprovedCommand` 는 `approveCommand`·`asApproved` 로만 만들어진다). 어댑터가 또 확인하면 규칙이
12
+ * 두 벌이 되고, 한쪽만 고쳐지는 날이 온다.
13
+ *
14
+ * ── 저장소에 닿는 자리를 밖으로 낸다 ─────────────────────────────────────
15
+ * 훅에서와 같은 이유다(§`HookStore`). 시험이 **재기동을 넣을 수 있어야** 한다 — 승인이 프로세스 안에만
16
+ * 살아 있으면 「승인된다」는 보이지만 「다시 세운 뒤에도 승인이 남아 있나」는 보이지 않는다.
17
+ */
18
+ import {
19
+ approveCommand,
20
+ asApproved,
21
+ nextCommandState,
22
+ passThroughReversible,
23
+ requiresApproval,
24
+ type ApprovedCommand,
25
+ type CommandApproval,
26
+ type TwinCommand
27
+ } from '@operato/ops-contract'
28
+
29
+ /** 조치의 결과 — 어댑터가 돌려준다. */
30
+ export interface CommandAck {
31
+ ok: boolean
32
+ /** 저쪽 문서 · 작업 식별자. 되돌릴 때 무엇을 되돌릴지 말하려면 이것이 있어야 한다. */
33
+ ref?: string
34
+ error?: string
35
+ }
36
+
37
+ /** 커맨드가 사는 자리 — 재기동을 넘어야 한다. */
38
+ export interface CommandStore {
39
+ load(domainId: string, id: string): Promise<TwinCommand | null>
40
+ save(domainId: string, command: TwinCommand): Promise<void>
41
+ }
42
+
43
+ /**
44
+ * 승인한다.
45
+ *
46
+ * 승인할 수 없는 상태면 계약이 던진다 — 여기서 답으로 돌려주지 않는 이유는, 실패를 답으로 주면 부르는
47
+ * 쪽이 그것을 무시할 수 있기 때문이다.
48
+ */
49
+ export async function approve(
50
+ store: CommandStore,
51
+ domainId: string,
52
+ id: string,
53
+ approval: CommandApproval
54
+ ): Promise<TwinCommand> {
55
+ const command = await store.load(domainId, id)
56
+ if (!command) throw new Error(`모르는 커맨드다 (${id})`)
57
+
58
+ const approved = approveCommand(command, approval)
59
+ await store.save(domainId, approved)
60
+ return approved
61
+ }
62
+
63
+ /** 거절한다 — 여기서 끝난다. 되살아나지 않는다. */
64
+ export async function reject(
65
+ store: CommandStore,
66
+ domainId: string,
67
+ id: string,
68
+ approval: CommandApproval
69
+ ): Promise<TwinCommand> {
70
+ const command = await store.load(domainId, id)
71
+ if (!command) throw new Error(`모르는 커맨드다 (${id})`)
72
+
73
+ const to = nextCommandState(command.state, 'reject')
74
+ if (!to) throw new Error(`${command.state} 상태의 커맨드는 거절할 수 없다 (${id})`)
75
+
76
+ const next: TwinCommand = { ...command, state: to, approval }
77
+ await store.save(domainId, next)
78
+ return next
79
+ }
80
+
81
+ /**
82
+ * 어댑터에 넘긴다 — **승인을 지나지 않은 것은 여기서 막힌다.**
83
+ *
84
+ * `actuate` 는 `ApprovedCommand` 만 받으므로, 승인 없이 이 함수를 지나가는 길이 없다. 저장소에서
85
+ * 되세운 커맨드는 타입이 지워진 채로 오므로 `asApproved` 로 다시 확인한다 — 확인 없이 표식을 붙이면
86
+ * **재기동이 게이트를 여는 길**이 된다.
87
+ *
88
+ * 되돌릴 수 있는 커맨드는 사람 없이 지난다(시뮬·what-if). 그 판단도 계약이 한다.
89
+ */
90
+ export async function dispatch(
91
+ store: CommandStore,
92
+ domainId: string,
93
+ id: string,
94
+ actuate: (command: ApprovedCommand) => Promise<CommandAck>
95
+ ): Promise<TwinCommand> {
96
+ const command = await store.load(domainId, id)
97
+ if (!command) throw new Error(`모르는 커맨드다 (${id})`)
98
+
99
+ /*
100
+ * 되돌릴 수 있는 것은 제안 상태에서 바로 지난다. 되돌릴 수 없는 것은 승인 기록이 있어야 하고,
101
+ * 없으면 여기서 던진다 — 조용히 건너뛰면 그 커맨드가 승인 없이 나간 것이 된다.
102
+ */
103
+ const approved: ApprovedCommand =
104
+ command.state === 'proposed' && !requiresApproval(command) ? passThroughReversible(command) : asApproved(command)
105
+
106
+ const to = nextCommandState(approved.state, 'dispatch')
107
+ if (!to) throw new Error(`${approved.state} 상태의 커맨드는 넘길 수 없다 (${id})`)
108
+
109
+ /*
110
+ * **넘기기 전에 「넘기는 중」을 먼저 적는다.** 넘기고 나서 적으면, 그 사이에 프로세스가 죽었을 때
111
+ * 저쪽은 받았는데 우리는 안 보낸 줄 알고 다시 보낸다. 현장이 두 번 움직인다.
112
+ */
113
+ const dispatching: TwinCommand = { ...approved, state: to }
114
+ await store.save(domainId, dispatching)
115
+
116
+ let ack: CommandAck
117
+ try {
118
+ ack = await actuate(approved)
119
+ } catch (e: any) {
120
+ const failed: TwinCommand = { ...dispatching, state: 'failed', error: String(e?.message ?? e) }
121
+ await store.save(domainId, failed)
122
+ return failed
123
+ }
124
+
125
+ const settled: TwinCommand = ack?.ok
126
+ ? { ...dispatching, state: 'acked', dispatchRef: ack.ref }
127
+ : { ...dispatching, state: 'failed', error: ack?.error ?? '어댑터가 이유를 말하지 않았다' }
128
+ await store.save(domainId, settled)
129
+ return settled
130
+ }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * 커맨드가 저장소에 닿는 두 자리 — 찾는 것과 적는 것.
3
+ *
4
+ * 디스패처가 엔티티를 직접 가져오면 그 파일을 부르는 시험이 TypeORM 데코레이터를 지나게 되고, 시험이
5
+ * 서지 않는다. 그러면 **게이트의 배선을 아무 시험도 걷지 않게 된다** — 훅에서 이미 겪은 부류다
6
+ * (§`hook-store.ts`).
7
+ */
8
+ import { getRepository } from '@things-factory/shell'
9
+
10
+ import type { TwinCommand } from '@operato/ops-contract'
11
+
12
+ import { hydratedDate } from '../../engine/read-time.js'
13
+
14
+ import { TwinCommandRecord } from './twin-command.js'
15
+ import type { CommandStore } from './command-dispatcher.js'
16
+
17
+ /**
18
+ * 행 → 커맨드. 상태 어휘는 계약이 정한 문자열 그대로다.
19
+ *
20
+ * `createdAt` 은 드라이버가 준 값이라 **직접 날짜로 만들지 않는다** — 드라이버마다 모양이 다르고,
21
+ * 문자열로 오는 드라이버에서는 시간대가 없는 값이 된다. `hydratedDate` 가 그 컬럼의 정의를 보고 푼다.
22
+ */
23
+ function toCommand(repo: any, row: TwinCommandRecord): TwinCommand {
24
+ return {
25
+ id: row.id,
26
+ instanceId: row.instanceId,
27
+ type: row.type,
28
+ ...(row.payload ? { payload: row.payload } : {}),
29
+ origin: row.origin as TwinCommand['origin'],
30
+ proposedAt: hydratedDate(repo, 'createdAt', row.createdAt)?.toISOString() ?? '',
31
+ /* 없는 것을 거짓으로 채우지 않는다 — 비어 있음이 「되돌릴 수 없다」의 뜻이다. */
32
+ ...(row.reversible === null || row.reversible === undefined ? {} : { reversible: row.reversible }),
33
+ state: row.state as TwinCommand['state'],
34
+ ...(row.decidedBy ? { approval: { by: row.decidedBy, at: row.decidedAt ?? '', ...(row.decisionNote ? { note: row.decisionNote } : {}) } } : {}),
35
+ ...(row.dispatchRef ? { dispatchRef: row.dispatchRef } : {}),
36
+ ...(row.error ? { error: row.error } : {})
37
+ }
38
+ }
39
+
40
+ /** 실제 저장소를 쓰는 구현 — 리졸버가 이것을 넣는다. */
41
+ export function databaseCommandStore(): CommandStore {
42
+ return {
43
+ load: async (domainId: string, id: string) => {
44
+ const repo = await getRepository(TwinCommandRecord)
45
+ const row = await repo.findOne({ where: { domain: { id: domainId }, id } }).catch(() => null)
46
+ return row ? toCommand(repo, row) : null
47
+ },
48
+ save: async (domainId: string, command: TwinCommand) => {
49
+ await getRepository(TwinCommandRecord).update(
50
+ { id: command.id, domain: { id: domainId } },
51
+ {
52
+ state: command.state,
53
+ decidedBy: command.approval?.by ?? null,
54
+ decidedAt: command.approval?.at ?? null,
55
+ decisionNote: command.approval?.note ?? null,
56
+ dispatchRef: command.dispatchRef ?? null,
57
+ error: command.error ?? null
58
+ } as any
59
+ )
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,8 @@
1
+ import { TwinCommandRecord } from './twin-command.js'
2
+
3
+ export const entities = [TwinCommandRecord]
4
+ export const resolvers = []
5
+
6
+ export * from './twin-command.js'
7
+ export * from './command-dispatcher.js'
8
+ export * from './command-store.js'
@@ -0,0 +1,105 @@
1
+ /*
2
+ * **현장에 내리는 조치 하나** — 제안 · 승인 · 넘김 · 응답이 한 행에 남는다.
3
+ *
4
+ * ── 왜 표인가 (2026-08-31) ───────────────────────────────────────────────
5
+ * 승인이 프로세스 안에만 있으면 트윈이 재기동한 순간 승인 대기 줄이 사라진다. 그때 사람은 **자기가
6
+ * 승인한 것이 실행됐는지 알 수 없다.** 조치는 되돌리기 어려운 것이므로 그 모름이 그대로 사고가 된다.
7
+ *
8
+ * 그리고 이 행은 **누가 언제 승인했나**의 기록이기도 하다. 사고 뒤에 그것을 물을 자리가 여기다.
9
+ *
10
+ * ── 판정은 여기 없다 ─────────────────────────────────────────────────────
11
+ * 어느 상태에서 어느 사건이 허용되는지는 계약이 든다(`@operato/ops-contract` §`nextCommandState`).
12
+ * 이 파일은 그 값을 담기만 한다 — 판정이 두 벌이 되면 화면과 서버가 다른 답을 낸다.
13
+ */
14
+ import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId, UpdateDateColumn } from 'typeorm'
15
+ import { Field, ID, ObjectType } from 'type-graphql'
16
+
17
+ import { Domain, ScalarObject } from '@things-factory/shell'
18
+
19
+ @Entity()
20
+ /* 대기 줄을 여는 질의가 이 세 칸으로 묻는다 — 어느 테넌트의 · 어느 트윈의 · 어느 상태인가. */
21
+ @Index('ix_twin_command_0', (e: TwinCommandRecord) => [e.domain, e.instanceId, e.state])
22
+ @ObjectType({ description: 'One actuation command proposed against a twin, with its approval trail.' })
23
+ export class TwinCommandRecord {
24
+ @PrimaryGeneratedColumn('uuid')
25
+ @Field(type => ID, { description: 'Unique identifier of the command.' })
26
+ readonly id: string
27
+
28
+ @ManyToOne(type => Domain)
29
+ @Field(type => Domain, { nullable: true, description: 'Tenant this command belongs to.' })
30
+ domain?: Domain
31
+
32
+ @RelationId((e: TwinCommandRecord) => e.domain)
33
+ domainId?: string
34
+
35
+ @Column()
36
+ @Field({ description: 'Twin instance this command acts on.' })
37
+ instanceId: string
38
+
39
+ @Column()
40
+ @Field({ description: 'Command type in twin vocabulary. Translating it into a legacy call is the adapter job.' })
41
+ type: string
42
+
43
+ @Column({ type: 'simple-json', nullable: true })
44
+ @Field(type => ScalarObject, { nullable: true, description: 'Command payload in twin vocabulary.' })
45
+ payload?: any
46
+
47
+ @Column()
48
+ @Field({ description: 'Who proposed it: operator, rule, ai or schedule. A person weighs this when approving.' })
49
+ origin: string
50
+
51
+ /**
52
+ * **되돌릴 수 있는가** — 게이트가 걸리는 기준이다.
53
+ *
54
+ * 비어 있으면 「되돌릴 수 없다」로 읽는다(§`requiresApproval`). 모름을 되돌릴 수 있다로 읽으면 그
55
+ * 커맨드가 승인 없이 나간다.
56
+ */
57
+ @Column({ nullable: true })
58
+ @Field({ nullable: true, description: 'Whether the effect can be undone. Absent means it cannot, and a person must approve.' })
59
+ reversible?: boolean
60
+
61
+ @Column()
62
+ @Field({ description: 'proposed · approved · rejected · dispatched · acked · failed.' })
63
+ state: string
64
+
65
+ /* ── 승인의 기록 ─────────────────────────────────────────────────────────
66
+ * 한 칸에 JSON 으로 담지 않고 세 칸으로 나눈다 — 「누가 승인했나」로 묻는 질의가 실제로 온다.
67
+ */
68
+ @Column({ nullable: true })
69
+ @Field({ nullable: true, description: 'Who approved or rejected it.' })
70
+ decidedBy?: string
71
+
72
+ /*
73
+ * ISO 문자열로 든다 — `datetime` 은 드라이버 방언이라 다섯 드라이버를 그대로 지나지 못하고,
74
+ * sqlite 는 그것을 검증하지 않아 개발에서 잡히지 않는다. 이 저장소의 다른 시각 칸도 그 모양이다
75
+ * (§`TwinReference.lastSyncedAt`). 계약의 `CommandApproval.at` 도 ISO 문자열이라 옮길 것이 없다.
76
+ */
77
+ @Column({ nullable: true })
78
+ @Field({ nullable: true, description: 'When it was approved or rejected (ISO 8601).' })
79
+ decidedAt?: string
80
+
81
+ @Column({ nullable: true })
82
+ @Field({ nullable: true, description: 'Why — free text the approver wrote.' })
83
+ decisionNote?: string
84
+
85
+ /**
86
+ * 어댑터가 돌려준 것 — 저쪽에서 무엇이 되었는지 되짚을 수 있어야 한다.
87
+ *
88
+ * 이것이 없으면 되돌릴 때 **무엇을 되돌릴지 말할 수 없다.**
89
+ */
90
+ @Column({ nullable: true })
91
+ @Field({ nullable: true, description: 'Reference the adapter returned. Without it a reversal cannot name what to undo.' })
92
+ dispatchRef?: string
93
+
94
+ @Column({ nullable: true })
95
+ @Field({ nullable: true, description: 'Why the dispatch failed. The server sentence, kept as it came.' })
96
+ error?: string
97
+
98
+ @CreateDateColumn()
99
+ @Field({ nullable: true, description: 'When the command was proposed.' })
100
+ createdAt?: Date
101
+
102
+ @UpdateDateColumn()
103
+ @Field({ nullable: true, description: 'When the row last changed.' })
104
+ updatedAt?: Date
105
+ }
@@ -31,6 +31,8 @@ import { entities as TwinStructureEntities } from './twin-structure/index.js'
31
31
  import { entities as TwinSpaceEntities, resolvers as TwinSpaceResolvers } from './twin-space/index.js'
32
32
  import { entities as TwinTargetEntities, resolvers as TwinTargetResolvers } from './twin-target/index.js'
33
33
  import { entities as TwinReferenceEntities, resolvers as TwinReferenceResolvers } from './reference/index.js'
34
+ /* 현장에 내리는 조치 — 승인이 재기동을 넘어 살아야 하므로 표로 든다(§`actuation/twin-command.ts`). */
35
+ import { entities as TwinActuationEntities } from './actuation/index.js'
34
36
  import { resolvers as TwinStateResolvers } from './twin-state/index.js'
35
37
  import { resolvers as TwinControlResolvers } from './twin-control/index.js'
36
38
  import { entities as TwinModelEntities, resolvers as TwinModelResolvers } from './twin-model/index.js'
@@ -56,6 +58,7 @@ export const entities = [
56
58
  ...TwinSpaceEntities,
57
59
  ...TwinTargetEntities,
58
60
  ...TwinReferenceEntities,
61
+ ...TwinActuationEntities,
59
62
  /* 구조 투영 — model JSON 에서 풀어낸 표준 엔티티(ADR-0032). 원본에서 온 캐시. */
60
63
  ...TwinModelEntities
61
64
  ]
@@ -42,6 +42,8 @@
42
42
  * 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지
43
43
  * 함께 알립니다.
44
44
  */
45
+ import { WEBHOOK_HEADER, verifyWebhookSignature } from '@operato/ops-contract/webhook'
46
+
45
47
  /** 훅 요청의 결과 — 상태 코드와 본문을 함께 정한다(부르는 쪽이 그대로 답한다). */
46
48
  export interface HookOutcome {
47
49
  status: number
@@ -49,28 +51,18 @@ export interface HookOutcome {
49
51
  }
50
52
 
51
53
  /**
52
- * 응답 코드 규약 — **재시도로 해결되는 것과 그렇지 않은 것을 가른다.**
53
- *
54
- * 밀어 주는 쪽은 응답 코드로 재시도를 정합니다. 그래서 우리가 코드를 아무렇게나 주면 두 가지가
55
- * 일어납니다: 고칠 없는 것을 영원히 다시 보내거나, 잠깐의 문제로 사라진 사실을 아무도 모릅니다.
56
- *
57
- * 200 받아서 반영했다
58
- * 401 비밀값이 다르다 재시도해도 같다
59
- * 404 모르는 연결이거나 모르는 트윈 재시도해도 같다
60
- * 400 본문을 우리 레코드로 옮길 수 없다 재시도해도 같다(그쪽 모양이 바뀐 것이다)
61
- * 409 그 트윈이 지금 실시간으로 돌지 않는다 사람이 트윈을 띄워야 한다
62
- * 501 그 커넥터가 훅을 받을 모른다 코드가 없다
63
- * 500 우리 쪽 오류 재시도하면 될 수 있다
54
+ * 응답 코드 규약 — **계약이 정하고 여기서는 그것을 쓴다**(2026-08-31).
55
+ *
56
+ * 예전에는 표가 여기 있었고 보내는 쪽(operato-mes)에 또 있었다. 그래서 같은 409 양쪽이 다른
57
+ * 뜻으로 썼다 여기서는 「그 트윈이 실시간으로 돌지 않는다」, 저쪽에서는 「번호가 비었다」였다.
58
+ * 붙였다면 트윈이 안 도는 동안 보내는 쪽이 같은 구간을 계속 다시 보냈다.
59
+ *
60
+ * 판정표는 만드는 쪽과 읽는 쪽이 합의해야 하는 것이라 계약 층에 있다(`@operato/ops-contract`).
61
+ * `notLive` 409 에서 503 으로 옮겨진 이유도 그 파일에 적혀 있다.
62
+ *
63
+ * **여기서 이름을 바꿔 다시 내보내지 않는다.** 부르는 쪽은 `WEBHOOK_STATUS` 를 계약에서 바로 가져온다.
64
+ * 별명을 두면 같은 표에 이름이 둘이 되고, 다음 사람이 어느 쪽이 정본인지 물어야 한다.
64
65
  */
65
- export const HOOK_STATUS = {
66
- ok: 200,
67
- badSecret: 401,
68
- unknownTarget: 404,
69
- badPayload: 400,
70
- notLive: 409,
71
- unsupported: 501,
72
- failed: 500
73
- } as const
74
66
 
75
67
  /** 헤더에서 비밀값을 꺼낸다 — 두 가지 방식을 받는다(밀어 주는 쪽의 관습이 갈린다). */
76
68
  export function secretFromHeaders(headers: Record<string, unknown> | undefined): string {
@@ -94,6 +86,92 @@ export function secretMatches(given: string, expected: string): boolean {
94
86
  return diff === 0
95
87
  }
96
88
 
89
+ /**
90
+ * 인증 — **서명이 왔으면 서명을 보고, 아니면 비밀값을 본다.**
91
+ *
92
+ * 두 방식을 두는 이유는 밀어 주는 쪽의 사정이 다르기 때문이다. 이미 붙어 있는 커넥터는 비밀값을 헤더에
93
+ * 싣는 방식으로 만들어졌고, 새로 만드는 것(operato-mes)은 서명을 쓴다. 서명 쪽이 낫다 — 비밀값을 그대로
94
+ * 싣는 방식은 그 요청을 그대로 다시 보내는 것을 막지 못한다.
95
+ *
96
+ * **서명이 왔는데 확인할 수 없으면 거절한다.** 받은 바이트를 들고 있지 않으면(`rawBody` 가 없으면)
97
+ * 파싱한 객체를 다시 문자열로 만들어 확인하고 싶어지는데, 키 순서와 공백이 달라져 어차피 맞지 않는다.
98
+ * 맞지 않는 것을 통과시키면 서명이 있으나 마나 한 것이 된다.
99
+ */
100
+ export function authorizeHook(args: {
101
+ headers: Record<string, unknown> | undefined
102
+ /** 받은 바이트 그대로. 서명을 쓰지 않는 연결에서는 없어도 된다. */
103
+ rawBody: string | undefined
104
+ /** 연결 설정에 저장된 비밀값. 서명과 비밀값이 같은 값을 쓴다. */
105
+ secret: string
106
+ nowMs: number
107
+ }): { ok: true; method: 'signature' | 'secret' } | { ok: false; reason: string } {
108
+ const { headers, rawBody, secret, nowMs } = args
109
+ const pick = (k: string) => String((headers ?? {})[k] ?? '').trim()
110
+ const signature = pick(WEBHOOK_HEADER.signature)
111
+
112
+ if (signature) {
113
+ if (rawBody === undefined) {
114
+ return { ok: false, reason: 'signed request but the raw body was not kept — cannot verify' }
115
+ }
116
+ const verdict = verifyWebhookSignature({
117
+ secret,
118
+ timestamp: pick(WEBHOOK_HEADER.timestamp),
119
+ signature,
120
+ body: rawBody,
121
+ nowMs
122
+ })
123
+ /* `=== false` 로 본다 — 이 저장소는 `strictNullChecks` 가 꺼져 있어 참·거짓만으로는 좁혀지지 않는다. */
124
+ if (verdict.ok === false) return { ok: false, reason: `signature ${verdict.reason}` }
125
+ return { ok: true, method: 'signature' }
126
+ }
127
+
128
+ return secretMatches(secretFromHeaders(headers), secret)
129
+ ? { ok: true, method: 'secret' }
130
+ : { ok: false, reason: 'hook secret does not match' }
131
+ }
132
+
133
+ /**
134
+ * 커서에 적을 열쇠 — **트윈까지 넣는다.**
135
+ *
136
+ * ── 왜 (2026-08-31, MES 레인 질문에서 나옴) ───────────────────────────────
137
+ * 커서는 **연결마다** 한 행(`TwinReference.liveCursor`)에 산다. 그런데 연결 하나가 트윈 여럿을
138
+ * 만든다(`scopeSpec.produced` 가 목록이다). 보내는 쪽의 단위 이름만으로 열쇠를 잡으면, 같은 연결의
139
+ * 두 공장이 같은 이름(`EVENTS`)을 쓸 때 **한 계수기를 함께 쓴다.**
140
+ *
141
+ * 그러면 A 공장이 5까지 올린 뒤 B 공장이 1을 보내면 이미 본 번호가 되어 버려지고, A 가 6을 보내면
142
+ * B 의 번호와 어긋나 끊임없이 「빠졌다」가 된다. 같은 부류가 이 저장소에서 이미 났다 — 두 공장의
143
+ * 같은 번호가 한 자리에 겹쳤다.
144
+ *
145
+ * 보내는 쪽에 트윈 이름을 넣으라고 하지 않는다. 어디로 갈지는 주소가 이미 말했고, 그것을 본문에도
146
+ * 적으라고 하면 둘이 어긋나는 날 어느 쪽을 믿을지 정해야 한다.
147
+ */
148
+ export function pushCursorKey(instanceId: string, scope: string): string {
149
+ return `${instanceId}::${scope}`
150
+ }
151
+
152
+ /**
153
+ * 커서에서 그 단위의 마지막 번호를 꺼낸다.
154
+ *
155
+ * 커서 모양은 `{ streams: { [흐름]: { since?, seen[], seq? } } }` 이고 **흐름 이름은 어댑터가 정한다**
156
+ * (§`TwinReference.liveCursor`). 밀어 주는 연결에서는 그 이름이 곧 번호를 매기는 단위다.
157
+ *
158
+ * 자리를 따로 만들지 않는 이유는 「재기동을 넘어 사는 진행 위치」가 두 벌이 되기 때문이다. 폴링이 쓰는
159
+ * `since` 와 밀어 주기가 쓰는 `seq` 는 둘 다 「이 흐름을 어디까지 읽었나」이고 원본이 되풀어 줄 수 있다.
160
+ */
161
+ export function seqOf(cursor: unknown, scope: string): number | undefined {
162
+ const streams = (cursor as any)?.streams
163
+ const seq = streams?.[scope]?.seq
164
+ return typeof seq === 'number' && Number.isInteger(seq) ? seq : undefined
165
+ }
166
+
167
+ /** 그 단위의 번호만 바꾼 커서를 만든다 — 다른 흐름과 `since`·`seen` 은 그대로 둔다. */
168
+ export function withSeq(cursor: unknown, scope: string, seq: number): Record<string, unknown> {
169
+ const base = (cursor && typeof cursor === 'object' ? (cursor as Record<string, unknown>) : {}) as Record<string, any>
170
+ const streams = base.streams && typeof base.streams === 'object' ? { ...base.streams } : {}
171
+ streams[scope] = { ...(streams[scope] ?? {}), seq }
172
+ return { ...base, streams }
173
+ }
174
+
97
175
  /** 이 연결이 이 트윈을 만들었나 — 남의 트윈에 밀어 넣지 못하게. */
98
176
  export function producedInstance(ref: { scopeSpec?: any } | null | undefined, instanceId: string): { siteId: string } | undefined {
99
177
  const produced: any[] = (ref?.scopeSpec as any)?.produced ?? []
@@ -0,0 +1,28 @@
1
+ /*
2
+ * 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것.
3
+ *
4
+ * ── 왜 갈라져 있나 (2026-08-31) ──────────────────────────────────────────
5
+ * `reference-hook.ts` 가 엔티티를 직접 가져오면 그 파일을 부르는 시험이 TypeORM 데코레이터를 지나게
6
+ * 되고, 시험이 서지 않는다. 실제로 훅 시험이 규약 파일(`hook-contract.ts`)만 부르고 있었던 이유가
7
+ * 그것이다 — 그래서 **배선은 아무 시험도 걷지 않았다.**
8
+ *
9
+ * 저장소에 닿는 두 함수만 여기로 내면, 처리 순서 전체를 시험이 같은 함수로 걸을 수 있다. 시험은
10
+ * 저장본을 손에 들고 있는 구현을 넣어 재기동까지 넣는다.
11
+ */
12
+ import { getRepository } from '@things-factory/shell'
13
+
14
+ import { TwinReference } from './twin-reference.js'
15
+ import type { HookStore } from './reference-hook.js'
16
+
17
+ /** 실제 저장소를 쓰는 구현 — 라우터가 이것을 넣는다. */
18
+ export function databaseStore(): HookStore {
19
+ return {
20
+ load: (domainId: string, source: string) =>
21
+ getRepository(TwinReference)
22
+ .findOne({ where: { domain: { id: domainId }, source } })
23
+ .catch(() => null),
24
+ saveCursor: async (refId: string, cursor: unknown) => {
25
+ await getRepository(TwinReference).update({ id: refId }, { liveCursor: cursor } as any)
26
+ }
27
+ }
28
+ }
@@ -1,3 +1,5 @@
1
+ import type { ApprovedCommand } from '@operato/ops-contract'
2
+
1
3
  import type { ReferenceStep } from './reference-progress.js'
2
4
  import type { ReferenceMaster } from './reference-master.js'
3
5
  import { twinWarn } from '../../engine/log.js'
@@ -5,7 +7,8 @@ import { twinWarn } from '../../engine/log.js'
5
7
  /*
6
8
  * 레퍼런스 어댑터 계약 (reference-management P1) — 정적 마스터 등록(`registerReference`)을
7
9
  * "어댑터 타입" 등록으로 일반화한다. 레퍼런스 = 데이터(TwinReference 행), 어댑터 = 동작.
8
- * inbound 전용(구조·상태 유입). 아웃바운드 액추에이션은 command-routing(별도 ActuationAdapter) 소유.
10
+ * 아웃바운드 액추에이션은 **별개 어댑터가 아니라 이 어댑터의 반대 면**이다(`actuate`) — 같은 연결이
11
+ * 사실을 읽고 조치를 내리므로 인증과 설정이 한 자리에 있어야 한다(command-routing §8.1).
9
12
  * seam: openLiveFeed 레코드 → 커널 face2-adapter.ingest → CanonicalEnvelope 까지가 여기,
10
13
  * 이후 projector 구동은 face2-inbound-live 소유.
11
14
  * 설계 SoT: operato-twin/design/plans/reference-management.md §2.2·§4.2.
@@ -163,6 +166,43 @@ export interface ObservedItemFact {
163
166
  lot?: string
164
167
  }
165
168
 
169
+ /**
170
+ * 밀어 받은 본문 하나를 옮긴 결과 (2026-08-31).
171
+ *
172
+ * 레코드만 돌려주던 것을 넓혔다. 번호를 함께 받아야 프레임워크가 빠진 것을 알아챈다.
173
+ *
174
+ * **`scope` 가 없으면 `seq` 도 없는 것으로 다룬다.** 번호만 있고 그 번호가 어느 줄의 것인지 모르면
175
+ * 커서를 무엇으로 잡을지 짐작해야 하고, 짐작한 열쇠는 보내는 쪽이 줄을 하나 더 늘리는 날 어긋난다.
176
+ * 실제로 operato-mes 가 `[domain, channel]` 마다 번호를 매기면서 봉투에 `channel` 을 싣지 않고 있었다.
177
+ */
178
+ export interface InboundBatch {
179
+ /**
180
+ * 옮긴 것들. 우리와 무관한 본문이면 빈 배열이다.
181
+ *
182
+ * **한 요청에 봉투가 여럿 온다.** 밀어 주는 쪽은 한 건씩 보내지 않는다 — 번호도 봉투마다 붙는다.
183
+ * 배치를 대표하는 번호 하나로는 가운데가 빠진 것을 알아채지 못한다.
184
+ */
185
+ items: InboundItem[]
186
+ /**
187
+ * 이 요청의 번호가 어느 줄의 것인가. 번호를 매기지 않는 원본은 주지 않는다.
188
+ *
189
+ * 요청 하나가 한 줄에만 속한다 — 한 요청에 두 줄을 섞으면 어느 줄의 어디까지 받았는지를 응답
190
+ * 하나로 말할 수 없다.
191
+ */
192
+ scope?: string
193
+ }
194
+
195
+ export interface InboundItem {
196
+ /** 우리 어휘로 옮긴 레코드 하나. */
197
+ record: unknown
198
+ /**
199
+ * 이 봉투의 번호.
200
+ *
201
+ * **한 배치 안에서 있거나 없거나 하나로 통일한다.** 섞이면 빠진 것이 있는지 판단할 근거가 없다.
202
+ */
203
+ seq?: number
204
+ }
205
+
166
206
  /**
167
207
  * 재기동을 넘어 이어지는 읽기 상태 — 참조 계층이 소유하고 어댑터에 건넨다(§`openLiveFeed`).
168
208
  *
@@ -366,8 +406,33 @@ export interface ReferenceAdapter {
366
406
  * ── 판단은 여기서 한다 ────────────────────────────────────────────────────
367
407
  * 통신이 끊긴 설비의 값을 보내지 않는 것, 잰 시각이 나아가지 않은 줄을 거르는 것 — 폴링에서 하던
368
408
  * 판단을 여기서도 한다. 그래야 두 길이 같은 답을 낸다.
409
+ *
410
+ * ── 번호는 프레임워크가 본다 (2026-08-31) ─────────────────────────────────
411
+ * 빠진 번호를 알아채는 일은 밀어 주는 모든 연결에 같은 규율이라야 한다. 커넥터마다 만들면 한 곳이
412
+ * 빠지고, 빠진 그 연결에서만 사실이 없어진다. 그래서 커넥터는 **번호를 꺼내 주기만** 하고 판정은
413
+ * 프레임워크가 한다(§`reference-hook`).
414
+ */
415
+ handleInbound?(cfg: ConnectionConfig, site: { siteId: string }, body: unknown, headers: Record<string, unknown>): InboundBatch
416
+
417
+ /**
418
+ * **현장에 조치를 내린다** — 인바운드의 반대 면(§`command-routing.md` §8.1).
419
+ *
420
+ * 별개 어댑터를 두지 않는다. 같은 연결이 사실을 읽고 조치를 내리므로, 인증과 설정이 한 자리에 있어야
421
+ * 한다. 둘로 나누면 같은 시스템에 두 벌의 연결 설정이 생긴다.
422
+ *
423
+ * ── 승인은 이미 지났다 (2026-08-31) ───────────────────────────────────────
424
+ * 인자가 `ApprovedCommand` 다 — 그 표식은 승인을 지난 커맨드에만 붙고, 디스패처가 붙인다
425
+ * (§`command-dispatcher.ts`). **어댑터는 승인을 다시 확인하지 않는다.** 확인이 두 곳에 있으면
426
+ * 규칙이 두 벌이 되고 한쪽만 고쳐지는 날이 온다.
427
+ *
428
+ * 이 자리를 게이트보다 먼저 열지 않은 이유가 있다 — 자리가 있으면 누군가 부르고, 그때 사람 승인 없이
429
+ * 현장에 작업지시가 나간다. 작업지시는 취소해도 이미 만든 것이 남는다.
430
+ *
431
+ * ── 돌려줄 것 ─────────────────────────────────────────────────────────────
432
+ * `ref` 를 **반드시** 돌려준다. 그것이 없으면 넘긴 것이 저쪽에서 무엇이 되었는지 되짚을 수 없고,
433
+ * 되돌릴 때 무엇을 되돌릴지 말할 수 없다.
369
434
  */
370
- handleInbound?(cfg: ConnectionConfig, site: { siteId: string }, body: unknown, headers: Record<string, unknown>): unknown[]
435
+ actuate?(cfg: ConnectionConfig, site: SiteDescriptor, command: ApprovedCommand): Promise<{ ok: boolean; ref?: string; error?: string }>
371
436
 
372
437
  openLiveFeed?(
373
438
  cfg: ConnectionConfig,