@things-factory/headless-twin 10.1.17 → 10.1.18
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/service/reference/reference-adapter.d.ts +12 -4
- package/dist-server/service/reference/reference-adapter.js +59 -30
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/package.json +5 -5
- package/server/service/actuation/command-dispatcher.ts +25 -4
- package/server/service/reference/actuation-routing.ts +29 -2
- package/server/service/reference/actuation-verification.ts +43 -0
- package/server/service/reference/connection-portability.ts +49 -5
- package/server/service/reference/reference-adapter.ts +124 -67
- package/server/service/reference/reference-resolver.ts +17 -4
- package/server/service/reference/twin-reference.ts +47 -0
- package/test/actuation-approval-door.test.ts +18 -2
- package/test/actuation-seam.test.ts +11 -5
- package/test/actuation-verification-state.test.ts +94 -0
- package/test/connector-capability-declaration.test.ts +60 -3
- package/test/rename-twin.test.ts +9 -1
- package/test/save-intake-collision.test.ts +91 -0
- package/test/twin-event-keys.test.ts +11 -1
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 조치 왕복의 결과를 레퍼런스에 어떻게 적을 것인가 — **순수하게.**
|
|
3
|
+
*
|
|
4
|
+
* `actuation-routing.ts` 에 두면 시험이 그 파일을 들여야 하고, 그 파일은 엔티티를 값으로 들인다
|
|
5
|
+
* (데코레이터가 붙은 클래스). 가벼운 모듈로 떼어 두면 판단만 따로 확인할 수 있다.
|
|
6
|
+
*
|
|
7
|
+
* 여기서 들이는 것은 **타입뿐**이다 — 런타임에 남지 않는다.
|
|
8
|
+
*/
|
|
9
|
+
import type { ActuationFailure, ActuationState } from './twin-reference.js'
|
|
10
|
+
import type { ActuationResult } from './reference-adapter.js'
|
|
11
|
+
|
|
12
|
+
/** 레퍼런스에 적을 칸들. `actuationVerifiedAt` 이 없는 것은 「건드리지 않는다」는 뜻이다. */
|
|
13
|
+
export interface ActuationOutcomePatch {
|
|
14
|
+
actuationState: ActuationState
|
|
15
|
+
actuationVerifiedAt?: string
|
|
16
|
+
actuationFailure: ActuationFailure | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 결과 → 적을 것.
|
|
21
|
+
*
|
|
22
|
+
* ── 세 가지를 지킨다 ───────────────────────────────────────────────────────
|
|
23
|
+
* **받아들여지면 지난 거절을 지운다.** 남겨 두면 화면이 「됐는데 실패 사유가 있다」를 그린다.
|
|
24
|
+
*
|
|
25
|
+
* **거절은 성공 시각을 건드리지 않는다.** 「전에 한 번 닿았다」와 「이번에 안 됐다」는 둘 다 사실이고,
|
|
26
|
+
* 지우면 앞엣것이 사라진다 — 사람이 「한 번이라도 닿은 적 있나」를 물을 자리가 없어진다.
|
|
27
|
+
*
|
|
28
|
+
* **코드가 없으면 사유를 비운다.** 자리를 채우려고 `error` 문장을 옮기지 않는다. `lastError` 가 이미
|
|
29
|
+
* 겪었다 — 저장된 사람 문장은 번역도 집계도 안 되고, 커넥터가 그 문장을 고치는 날 옛 행과 새 행이
|
|
30
|
+
* 서로 다른 말을 한다. 모르는 것은 안 적는다.
|
|
31
|
+
*/
|
|
32
|
+
export function actuationOutcomePatch(result: ActuationResult, at: string): ActuationOutcomePatch {
|
|
33
|
+
if (result.ok) {
|
|
34
|
+
return { actuationState: 'verified', actuationVerifiedAt: at, actuationFailure: null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
actuationState: 'failed',
|
|
39
|
+
actuationFailure: result.errorCode
|
|
40
|
+
? { code: result.errorCode, at, ...(result.errorParams ? { params: result.errorParams } : {}) }
|
|
41
|
+
: null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -205,9 +205,8 @@ export function planImport(
|
|
|
205
205
|
* 잡습니다 — 그게 실제로 더 쉽게 나는 쪽입니다(하나는 화면에서, 하나는 파일에서 생깁니다).
|
|
206
206
|
*/
|
|
207
207
|
const after = [
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.map(e => ({ source: e.source, config: (e.connectionConfig ?? {}) as Record<string, unknown> })),
|
|
208
|
+
/* 행 모양 그대로 넘긴다 — 옮기는 자리는 `intakeCollisions` 안 한 곳이다(ADR-0053 ①). */
|
|
209
|
+
...existing.filter(e => !seen.has(e.source)),
|
|
211
210
|
...actions
|
|
212
211
|
.filter((a): a is Extract<ImportAction, { connection: PortableConnection }> => a.action !== 'skip')
|
|
213
212
|
.map(a => ({ source: a.connection.source, config: a.connection.config }))
|
|
@@ -267,11 +266,28 @@ export function secretEnvName(source: string, key: string): string {
|
|
|
267
266
|
* 그대로 따라옵니다.
|
|
268
267
|
*/
|
|
269
268
|
export function intakeCollisions(
|
|
270
|
-
connections: readonly {
|
|
269
|
+
connections: readonly {
|
|
270
|
+
source: string
|
|
271
|
+
adapterType?: string
|
|
272
|
+
/** 꾸러미·계획이 쓰는 이름. */
|
|
273
|
+
config?: Record<string, unknown> | null
|
|
274
|
+
/** **DB 행이 쓰는 이름** — `TwinReference` 의 칸이다. 둘 다 받는다(ADR-0053 ①). */
|
|
275
|
+
connectionConfig?: Record<string, unknown> | null
|
|
276
|
+
}[]
|
|
271
277
|
): { intake: string; sources: string[] }[] {
|
|
272
278
|
const byIntake = new Map<string, string[]>()
|
|
273
279
|
for (const c of connections) {
|
|
274
|
-
|
|
280
|
+
/*
|
|
281
|
+
* **모양이 둘인 것을 여기서 안다** (ADR-0053 ①, 2026-09-16).
|
|
282
|
+
*
|
|
283
|
+
* 꾸러미는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서, 행을
|
|
284
|
+
* 그대로 넘기면 같은 충돌에도 `[]` 를 냈다 — 오류도 경고도 없이 **저장이 통과하고 가드는 선 채로
|
|
285
|
+
* 아무 일도 안 한다.** 부르는 쪽마다 옮기게 두면 다음 소비처가 또 밟는다(이 함수는 화면이 쓰라고
|
|
286
|
+
* 리졸버에서 다시 내보내진다).
|
|
287
|
+
*
|
|
288
|
+
* 「없다」가 편한 답인 자리라 더 위험하다 — 빨강이 아니라 **조용한 초록**으로 흐른다.
|
|
289
|
+
*/
|
|
290
|
+
const cfg = c.config ?? c.connectionConfig ?? {}
|
|
275
291
|
/* 받는 자리를 정하는 규칙은 커넥터의 것과 같아야 합니다 — 여기서 다시 지으면 두 벌이 됩니다. */
|
|
276
292
|
const intake = String(cfg.intakeSiteId ?? cfg.siteId ?? '').trim()
|
|
277
293
|
/* 조치를 낼 수 없는 연결은 부딪힐 것이 없습니다 — 주소가 없으면 아무 데도 안 갑니다. */
|
|
@@ -283,3 +299,31 @@ export function intakeCollisions(
|
|
|
283
299
|
.filter(([, sources]) => sources.length > 1)
|
|
284
300
|
.map(([intake, sources]) => ({ intake, sources: sources.sort() }))
|
|
285
301
|
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* **화면 저장의 문** — 이 연결을 이 설정으로 저장하면 다른 연결과 받는 사이트가 겹치나 (ADR-0046 ②-1).
|
|
305
|
+
*
|
|
306
|
+
* 들여오기(`planImport`)는 `intakeCollisions` 로 거부하는데 화면 저장(`saveTwinReference`)은 부르지 않아서,
|
|
307
|
+
* 연결을 베껴 이름만 바꾸면 경고 0 으로 두 트윈의 조치가 한 공장에 섰다. 판정은 같은 함수 하나로 한다.
|
|
308
|
+
* 경고가 아니라 거부이고, 사람이 그 자리에서 고치도록 **이미 그 사이트를 쓰는 연결의 이름**을 싣는다.
|
|
309
|
+
*/
|
|
310
|
+
export function refuseIntakeCollision(
|
|
311
|
+
source: string,
|
|
312
|
+
config: Record<string, unknown> | undefined,
|
|
313
|
+
others: readonly { source: string; siteName?: string | null; connectionConfig?: Record<string, unknown> | null }[]
|
|
314
|
+
): { code: 'intake-site-taken'; message: string; params: { site: string; connection: string } } | null {
|
|
315
|
+
const rest = others.filter(o => o.source !== source)
|
|
316
|
+
/* `others` 는 DB 행이다 — 옮기지 않고 그대로 넘긴다(ADR-0053 ①). */
|
|
317
|
+
const hit = intakeCollisions([...rest, { source, config: config ?? {} }]).find(c => c.sources.includes(source))
|
|
318
|
+
if (!hit) return null
|
|
319
|
+
const connection = rest
|
|
320
|
+
.filter(o => hit.sources.includes(o.source))
|
|
321
|
+
.map(o => o.siteName || o.source)
|
|
322
|
+
.join(', ')
|
|
323
|
+
const site = String(config?.intakeSiteId ?? config?.siteId ?? '').trim()
|
|
324
|
+
return {
|
|
325
|
+
code: 'intake-site-taken',
|
|
326
|
+
message: `intake site ${site} is already used by ${connection}`,
|
|
327
|
+
params: { site, connection }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApprovedCommand } from '@operato/ops-contract'
|
|
1
|
+
import type { ApprovedCommand, RefusalCode, RefusalParams } from '@operato/ops-contract'
|
|
2
2
|
|
|
3
3
|
import type { ReferenceStep } from './reference-progress.js'
|
|
4
4
|
import type { ReferenceMaster } from './reference-master.js'
|
|
@@ -482,6 +482,63 @@ export interface ActuationReadiness {
|
|
|
482
482
|
|
|
483
483
|
import type { LiveCadence } from './live-cadence.js'
|
|
484
484
|
|
|
485
|
+
/**
|
|
486
|
+
* 조치를 넘긴 결과 — **어댑터가 돌려주는 것.**
|
|
487
|
+
*
|
|
488
|
+
* 이름을 붙인 이유는 두 가지다. 익명 타입이라 소비처가 그 모양을 말할 수 없었고(`actuate` 를 감싸는
|
|
489
|
+
* 쪽이 반환형을 다시 적어야 했다), 그리고 **거절 사유를 코드로 실을 자리가 없었다** — `error` 는 사람
|
|
490
|
+
* 문장이라 저장하면 번역과 판독이 함께 굳는다.
|
|
491
|
+
*
|
|
492
|
+
* `errorCode`·`errorParams` 는 `CommandAck` 과 **같은 낱말**이다(ADR-0037 ④ · ADR-0042 ③). 셋째 이름을
|
|
493
|
+
* 만들지 않는다 — 같은 것을 세 이름으로 부르면 매핑 표가 생기고, 그 표는 커넥터가 새 코드를 내는 날
|
|
494
|
+
* 「알 수 없는 사유」로 떨어진다.
|
|
495
|
+
*/
|
|
496
|
+
export interface ActuationResult {
|
|
497
|
+
ok: boolean
|
|
498
|
+
ref?: string
|
|
499
|
+
/**
|
|
500
|
+
* **왜 거절됐나 — 언어 중립 코드.** 저장되는 값은 이것이고 `error` 는 아니다.
|
|
501
|
+
*
|
|
502
|
+
* `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 그러면 번역도 못 하고,
|
|
503
|
+
* 무엇이 났는지 세지도 못하고, 문장을 고치는 순간 옛 행과 새 행이 달라진다.
|
|
504
|
+
*/
|
|
505
|
+
errorCode?: RefusalCode
|
|
506
|
+
/** 그 코드가 가리키는 원시값들 — 표현층이 문장에 끼운다. */
|
|
507
|
+
errorParams?: RefusalParams
|
|
508
|
+
/**
|
|
509
|
+
* **받아들였으나 남은 것이 있다** — 실패가 아니다. 사람이 저쪽에서 해야 할 일이 있으면 여기 적는다.
|
|
510
|
+
*
|
|
511
|
+
* `error` 에 적지 말 것 — 실패로 읽혀 커맨드가 `failed` 로 앉고, 다시 넘기게 된다. 실제로 MES 가
|
|
512
|
+
* 「지시서를 못 붙였다」를 답했을 때 커넥터가 적을 칸이 없어 로그로만 남겼고, 트윈 쪽에서는 성공한
|
|
513
|
+
* 조치와 구별되지 않았다.
|
|
514
|
+
*/
|
|
515
|
+
note?: string
|
|
516
|
+
/**
|
|
517
|
+
* 그 말 중 **다음에 할 일** 한 줄 — 원인은 위 `note` 다.
|
|
518
|
+
*
|
|
519
|
+
* 붙여 보내지 말 것. 읽는 사람은 「그래서 내가 뭘 해야 하나」를 먼저 찾고, 한 문장으로 오면
|
|
520
|
+
* **화면이 자르게 되며 자르는 규칙이 화면마다 생긴다.** 어댑터는 이미 둘로 알고 있다.
|
|
521
|
+
*/
|
|
522
|
+
noteNext?: string
|
|
523
|
+
error?: string
|
|
524
|
+
/**
|
|
525
|
+
* **다시 해서 될 일인가** — 실패했을 때만.
|
|
526
|
+
*
|
|
527
|
+
* again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다
|
|
528
|
+
* after-fix 사람이 고친 뒤 그대로 나간다 설정 문제
|
|
529
|
+
* never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다
|
|
530
|
+
*
|
|
531
|
+
* 가르는 자리는 **「조치를 다시 낼 필요가 있나」**다. 설정이 틀린 것은 조치가 멀쩡하므로
|
|
532
|
+
* `after-fix`, 지시 내용이 틀린 것은 그 조치가 영원히 틀렸으므로 `never` 다.
|
|
533
|
+
*
|
|
534
|
+
* **문장에 담지 말 것** — 일꾼이 그것을 쓰려면 파싱해야 하고, 번역되면 깨진다.
|
|
535
|
+
*
|
|
536
|
+
* 말하지 않으면 일꾼이 **집지 않는다.** 모르는 것을 `never` 로 접으면 고칠 수 있는 것을 사람이
|
|
537
|
+
* 포기하고, `again` 으로 접으면 없는 자재를 끝없이 두드린다.
|
|
538
|
+
*/
|
|
539
|
+
retry?: 'again' | 'after-fix' | 'never'
|
|
540
|
+
}
|
|
541
|
+
|
|
485
542
|
export interface ReferenceAdapter {
|
|
486
543
|
/** 레지스트리 키 (예: 'virtual' | 'sap-ewm' | 'custom-rest'). */
|
|
487
544
|
type: string
|
|
@@ -603,46 +660,7 @@ export interface ReferenceAdapter {
|
|
|
603
660
|
* `ref` 를 **반드시** 돌려준다. 그것이 없으면 넘긴 것이 저쪽에서 무엇이 되었는지 되짚을 수 없고,
|
|
604
661
|
* 되돌릴 때 무엇을 되돌릴지 말할 수 없다.
|
|
605
662
|
*/
|
|
606
|
-
actuate?(
|
|
607
|
-
cfg: ConnectionConfig,
|
|
608
|
-
site: SiteDescriptor,
|
|
609
|
-
command: ApprovedCommand
|
|
610
|
-
): Promise<{
|
|
611
|
-
ok: boolean
|
|
612
|
-
ref?: string
|
|
613
|
-
/**
|
|
614
|
-
* **받아들였으나 남은 것이 있다** — 실패가 아니다. 사람이 저쪽에서 해야 할 일이 있으면 여기 적는다.
|
|
615
|
-
*
|
|
616
|
-
* `error` 에 적지 말 것 — 실패로 읽혀 커맨드가 `failed` 로 앉고, 다시 넘기게 된다. 실제로 MES 가
|
|
617
|
-
* 「지시서를 못 붙였다」를 답했을 때 커넥터가 적을 칸이 없어 로그로만 남겼고, 트윈 쪽에서는 성공한
|
|
618
|
-
* 조치와 구별되지 않았다.
|
|
619
|
-
*/
|
|
620
|
-
note?: string
|
|
621
|
-
/**
|
|
622
|
-
* 그 말 중 **다음에 할 일** 한 줄 — 원인은 위 `note` 다.
|
|
623
|
-
*
|
|
624
|
-
* 붙여 보내지 말 것. 읽는 사람은 「그래서 내가 뭘 해야 하나」를 먼저 찾고, 한 문장으로 오면
|
|
625
|
-
* **화면이 자르게 되며 자르는 규칙이 화면마다 생긴다.** 어댑터는 이미 둘로 알고 있다.
|
|
626
|
-
*/
|
|
627
|
-
noteNext?: string
|
|
628
|
-
error?: string
|
|
629
|
-
/**
|
|
630
|
-
* **다시 해서 될 일인가** — 실패했을 때만.
|
|
631
|
-
*
|
|
632
|
-
* again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다
|
|
633
|
-
* after-fix 사람이 고친 뒤 그대로 나간다 설정 문제
|
|
634
|
-
* never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다
|
|
635
|
-
*
|
|
636
|
-
* 가르는 자리는 **「조치를 다시 낼 필요가 있나」**다. 설정이 틀린 것은 조치가 멀쩡하므로
|
|
637
|
-
* `after-fix`, 지시 내용이 틀린 것은 그 조치가 영원히 틀렸으므로 `never` 다.
|
|
638
|
-
*
|
|
639
|
-
* **문장에 담지 말 것** — 일꾼이 그것을 쓰려면 파싱해야 하고, 번역되면 깨진다.
|
|
640
|
-
*
|
|
641
|
-
* 말하지 않으면 일꾼이 **집지 않는다.** 모르는 것을 `never` 로 접으면 고칠 수 있는 것을 사람이
|
|
642
|
-
* 포기하고, `again` 으로 접으면 없는 자재를 끝없이 두드린다.
|
|
643
|
-
*/
|
|
644
|
-
retry?: 'again' | 'after-fix' | 'never'
|
|
645
|
-
}>
|
|
663
|
+
actuate?(cfg: ConnectionConfig, site: SiteDescriptor, command: ApprovedCommand): Promise<ActuationResult>
|
|
646
664
|
|
|
647
665
|
/**
|
|
648
666
|
* **저쪽이 조치를 받을 준비가 됐나** — 보내기 전에 묻는다. 선택이다.
|
|
@@ -849,19 +867,42 @@ export function groundingOf(adapter: ReferenceAdapter | undefined): {
|
|
|
849
867
|
return { level: g.level, ...(against ? { verifiedAgainst: against } : {}) }
|
|
850
868
|
}
|
|
851
869
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
870
|
+
/**
|
|
871
|
+
* 능력마다 **그것을 증명하는 메서드들** — 하나라도 있으면 성립한다.
|
|
872
|
+
*
|
|
873
|
+
* ── `live` 의 증명이 왜 둘인가 (2026-09-16, ADR-0036 ③ 개정) ─────────────────
|
|
874
|
+
* 예전에는 `openLiveFeed` 하나였다. 그런데 사실이 흘러 들어오는 방식은 **상대 시스템의 성질**이지
|
|
875
|
+
* 우리 능력의 종류가 아니다 — 우리가 당겨 오면 `openLiveFeed`, 상대가 웹훅으로 밀어 넣으면
|
|
876
|
+
* `handleInbound`(계약 일급 문)다. 둘 다 「이 원본에서 사실이 계속 온다」를 증명한다.
|
|
877
|
+
*
|
|
878
|
+
* 실측: `operato-plant` 은 outbox 가 밀어 넣는 쪽이라 `openLiveFeed` 가 없다. 그래서 **실 생산
|
|
879
|
+
* 사실을 나르는 유일한 커넥터가 판정에서 live 가 아니었다.** 등록 경고는 「구현했는데 선언 안 함」
|
|
880
|
+
* 한 방향만 봐서 아무 말도 하지 않았다.
|
|
881
|
+
*/
|
|
882
|
+
const PROVED_BY: Record<ReferenceCapability, readonly string[]> = {
|
|
883
|
+
live: ['openLiveFeed', 'handleInbound'],
|
|
884
|
+
control: ['control'],
|
|
885
|
+
actuate: ['actuate'],
|
|
859
886
|
/*
|
|
860
887
|
* **선언만으로 성립한다** — 부를 메서드가 없다. 「두 번 해도 같은가」는 어댑터가 하는 일이 아니고
|
|
861
888
|
* 저쪽 시스템의 성질이다. 그래서 구현 검사를 하지 않는다.
|
|
862
889
|
*/
|
|
863
|
-
|
|
864
|
-
|
|
890
|
+
'idempotent-actuation': []
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const CAPABILITIES = Object.keys(PROVED_BY) as ReferenceCapability[]
|
|
894
|
+
|
|
895
|
+
/** 그 능력을 증명하는 메서드가 실제로 붙어 있나. 증명이 필요 없는 능력은 언제나 참이다. */
|
|
896
|
+
function implemented(adapter: ReferenceAdapter, capability: ReferenceCapability): boolean {
|
|
897
|
+
const methods = PROVED_BY[capability]
|
|
898
|
+
if (methods.length === 0) return true
|
|
899
|
+
return methods.some(name => typeof (adapter as any)[name] === 'function')
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export function capabilitiesOf(adapter: ReferenceAdapter | undefined): ReferenceCapability[] {
|
|
903
|
+
if (!adapter) return []
|
|
904
|
+
const declared = new Set(adapter.capabilities ?? [])
|
|
905
|
+
return CAPABILITIES.filter(capability => declared.has(capability) && implemented(adapter, capability))
|
|
865
906
|
}
|
|
866
907
|
|
|
867
908
|
/* 어댑터 "타입" 레지스트리(전역·인메모리) — 레퍼런스 데이터는 DB(TwinReference), 어댑터 동작은 여기. */
|
|
@@ -888,18 +929,17 @@ const adapters = new Map<string, ReferenceAdapter>()
|
|
|
888
929
|
*/
|
|
889
930
|
export function registerAdapterType(adapter: ReferenceAdapter): void {
|
|
890
931
|
const declared = new Set(adapter.capabilities ?? [])
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const undeclared =
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
)
|
|
932
|
+
|
|
933
|
+
/*
|
|
934
|
+
* **어긋남은 양쪽으로 본다** (2026-09-16, ADR-0036 ③ 개정).
|
|
935
|
+
*
|
|
936
|
+
* 이 경고는 「구현했는데 선언 안 함」 한 방향만 봤다. 그래서 반대쪽 — 선언했는데 증명이 없는 것 —
|
|
937
|
+
* 은 `capabilitiesOf` 가 조용히 걸러 내기만 하고 아무도 그 사실을 듣지 못했다. 한 방향만 보면
|
|
938
|
+
* **있는 것을 없다고(이번 plant) 또는 없는 것을 있다고** 말하게 된다. 같은 무게로 말한다.
|
|
939
|
+
*/
|
|
940
|
+
const undeclared = CAPABILITIES.filter(c => PROVED_BY[c].length > 0 && implemented(adapter, c) && !declared.has(c))
|
|
941
|
+
const unproven = CAPABILITIES.filter(c => PROVED_BY[c].length > 0 && declared.has(c) && !implemented(adapter, c))
|
|
942
|
+
|
|
903
943
|
if (undeclared.length) {
|
|
904
944
|
twinWarn(
|
|
905
945
|
`[reference] connector "${adapter.type}" implements ${undeclared.join(' and ')} but does not declare it — ` +
|
|
@@ -907,6 +947,15 @@ export function registerAdapterType(adapter: ReferenceAdapter): void {
|
|
|
907
947
|
'Until then the screens treat this connector as unable to do it, and a live twin cannot be created from it.'
|
|
908
948
|
)
|
|
909
949
|
}
|
|
950
|
+
if (unproven.length) {
|
|
951
|
+
twinWarn(
|
|
952
|
+
`[reference] connector "${adapter.type}" declares ${unproven.join(' and ')} but implements none of ` +
|
|
953
|
+
`${unproven.map(c => PROVED_BY[c].map(m => `${m}()`).join(' or ')).join(', ')} — ` +
|
|
954
|
+
'the declaration is dropped, so the screens treat this connector as unable to do it. ' +
|
|
955
|
+
'Implement one of those methods or remove the capability.'
|
|
956
|
+
)
|
|
957
|
+
}
|
|
958
|
+
|
|
910
959
|
adapters.set(adapter.type, adapter)
|
|
911
960
|
}
|
|
912
961
|
export function getAdapter(type: string): ReferenceAdapter | undefined {
|
|
@@ -917,12 +966,20 @@ export function listAdapterTypes(): string[] {
|
|
|
917
966
|
}
|
|
918
967
|
/** 커넥터 메타데이터 목록(picker·연결 폼용). */
|
|
919
968
|
/**
|
|
920
|
-
* 등록된 어댑터 목록 —
|
|
969
|
+
* 등록된 어댑터 목록 — **능력은 판정값이다. 선언 원본이 아니다.**
|
|
970
|
+
*
|
|
971
|
+
* 능력은 이 목록을 읽는 화면이 「이 원본에 무엇을 요구할 수 있나」를 판단하는 근거다.
|
|
972
|
+
*
|
|
973
|
+
* ── 왜 선언을 그대로 내지 않나 (2026-09-16, ADR-0036 ③ 개정) ────────────────
|
|
974
|
+
* 여기서 `a.capabilities` 를 그대로 냈다. 그래서 `capabilitiesOf` — 선언과 구현이 어긋나면 **없는
|
|
975
|
+
* 것으로 읽는다**는 그 문 — 의 판정값을 **읽는 자리가 하나도 없었다.** 화면은 선언을 보고 있었고,
|
|
976
|
+
* 어느 커넥터든 구현 없이 능력을 적기만 하면 가능한 것으로 보였다. 선언을 믿지 않으려고 만든 문을
|
|
977
|
+
* 아무도 지나지 않은 셈이다.
|
|
921
978
|
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
979
|
+
* 커넥터 쪽에도 흔적이 남아 있었다 — `operato-plant` 주석이 「어댑터 목록은 선언한 값을 그대로
|
|
980
|
+
* 보여 주므로 남겨 두면 화면이 없는 능력을 있다고 말한다」며 선언을 손으로 줄여 두었다. 사람이
|
|
981
|
+
* 기억해서 맞추는 것은 다음 커넥터에서 끊긴다.
|
|
925
982
|
*/
|
|
926
983
|
export function listAdapters(): { type: string; meta?: AdapterMeta; capabilities?: ReferenceCapability[] }[] {
|
|
927
|
-
return [...adapters.values()].map(a => ({ type: a.type, meta: a.meta, capabilities: a
|
|
984
|
+
return [...adapters.values()].map(a => ({ type: a.type, meta: a.meta, capabilities: capabilitiesOf(a) }))
|
|
928
985
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
referenceNotFound, adapterUnknown, discoveryFailed, discoveryTimedOut
|
|
23
23
|
} from './discovery-result.js'
|
|
24
24
|
import { refuseImportSpace } from './ingest-space.js'
|
|
25
|
+
import { refuseIntakeCollision } from './connection-portability.js'
|
|
25
26
|
import { startReferenceLiveFeed } from './reference-live.js'
|
|
26
27
|
import { listTemplates, getTemplate } from './template-registry.js'
|
|
27
28
|
|
|
@@ -643,6 +644,21 @@ export class TwinReferenceResolver {
|
|
|
643
644
|
const domainId = context.state.domain.id
|
|
644
645
|
const repo = getRepository(TwinReference)
|
|
645
646
|
const existing = await repo.findOne({ where: { domain: { id: domainId }, source } })
|
|
647
|
+
// 필드 단위 병합 — 편집 시 생략된 키(예: 마스킹된 secret)는 기존 값 유지.
|
|
648
|
+
const connectionConfig = patch.connectionConfig
|
|
649
|
+
? { ...((existing?.connectionConfig as any) ?? {}), ...patch.connectionConfig }
|
|
650
|
+
: existing?.connectionConfig
|
|
651
|
+
|
|
652
|
+
/*
|
|
653
|
+
* 같은 받는 사이트를 가리키는 연결 둘은 **저장하는 순간 거부한다** (ADR-0046 ②-1).
|
|
654
|
+
* 들여오기(planImport)에는 있던 검사가 화면 저장에는 없어서, 연결을 베껴 이름만 바꾸면 두 트윈의
|
|
655
|
+
* 조치가 한 공장으로 갔다 — 저쪽은 200 으로 받고 실제로 만든다. 짝(intakeEndpoint·intakeSiteId)은
|
|
656
|
+
* 암호화된 칸 안에 있어 DB 유니크로 못 잡으니 여기서 잡는다(판정: connection-portability.ts).
|
|
657
|
+
*/
|
|
658
|
+
const others = await repo.find({ where: { domain: { id: domainId } } })
|
|
659
|
+
const taken = refuseIntakeCollision(source, connectionConfig as any, others as any)
|
|
660
|
+
if (taken) return { ok: false, source, error: taken.message, errorCode: taken.code, errorParams: taken.params }
|
|
661
|
+
|
|
646
662
|
const saved = await repo.save(
|
|
647
663
|
repo.create({
|
|
648
664
|
...(existing ?? {}),
|
|
@@ -652,10 +668,7 @@ export class TwinReferenceResolver {
|
|
|
652
668
|
siteName: patch.siteName ?? existing?.siteName,
|
|
653
669
|
description: patch.description ?? existing?.description,
|
|
654
670
|
adapterType: patch.adapterType ?? existing?.adapterType,
|
|
655
|
-
|
|
656
|
-
connectionConfig: patch.connectionConfig
|
|
657
|
-
? { ...((existing?.connectionConfig as any) ?? {}), ...patch.connectionConfig }
|
|
658
|
-
: existing?.connectionConfig,
|
|
671
|
+
connectionConfig,
|
|
659
672
|
mappingSpec: patch.mappingSpec ?? existing?.mappingSpec,
|
|
660
673
|
scopeSpec: patch.scopeSpec ?? existing?.scopeSpec,
|
|
661
674
|
status: patch.status ?? existing?.status ?? 'draft'
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
|
|
2
2
|
import { ObjectType, Field, ID, Int } from 'type-graphql'
|
|
3
3
|
|
|
4
|
+
import type { RefusalCode, RefusalParams } from '@operato/ops-contract'
|
|
5
|
+
|
|
4
6
|
import { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'
|
|
5
7
|
|
|
8
|
+
/** 조치 검증 상태 — 「아직 안 해 봤다」는 실패가 아니라 그 자체로 하나의 사실이다. */
|
|
9
|
+
export type ActuationState = 'unverified' | 'verified' | 'failed'
|
|
10
|
+
|
|
11
|
+
/** 마지막 거절 — 언어 중립 코드와 그 값들. 사람 문장은 담지 않는다. */
|
|
12
|
+
export interface ActuationFailure {
|
|
13
|
+
code: RefusalCode
|
|
14
|
+
/** 거절된 시각 (ISO 8601). */
|
|
15
|
+
at: string
|
|
16
|
+
params?: RefusalParams
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
/*
|
|
7
20
|
* TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018).
|
|
8
21
|
* 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티.
|
|
@@ -95,6 +108,40 @@ export class TwinReference {
|
|
|
95
108
|
@Field({ nullable: true, description: 'Last connection/sync error message, if any.' })
|
|
96
109
|
lastError?: string
|
|
97
110
|
|
|
111
|
+
/**
|
|
112
|
+
* **조치를 이 주소로 실제로 넘겨 봤나** (ADR-0046 ②).
|
|
113
|
+
*
|
|
114
|
+
* ── 왜 세 값인가 ───────────────────────────────────────────────────────────
|
|
115
|
+
* 설정이 맞는 것과 저쪽이 받는 것은 다르다. 주소도 비밀값도 다 적혀 있는데 저쪽 프로세스가 그 값을
|
|
116
|
+
* 잃은 일이 실제로 있었다(§`actuationReadiness`). 그것은 **한 번 넘겨 봐야** 알 수 있고, 그래서
|
|
117
|
+
* 「아직 안 해 봤다」와 「해 봤고 됐다」와 「해 봤고 안 됐다」가 다른 사실이다.
|
|
118
|
+
*
|
|
119
|
+
* unverified 아직 왕복이 없다 — 실패가 아니다. 새 연결과 설정이 바뀐 연결이 여기 선다
|
|
120
|
+
* verified `actuate` 가 받아들여졌다. 그 시각이 `actuationVerifiedAt`
|
|
121
|
+
* failed 거절됐다. 사유는 `actuationFailure` 에 **코드로**
|
|
122
|
+
*
|
|
123
|
+
* `unverified` 를 `failed` 로 접지 않는다 — 안 해 본 것을 실패로 그리면 사람이 고칠 것을 찾으러
|
|
124
|
+
* 가고, 고칠 것이 없다.
|
|
125
|
+
*/
|
|
126
|
+
@Column({ nullable: true })
|
|
127
|
+
@Field({ nullable: true, description: "Whether an actuation has actually reached this peer: 'unverified' | 'verified' | 'failed'. Null means the same as unverified; a connection that has never been tried is not a failed one." })
|
|
128
|
+
actuationState?: ActuationState
|
|
129
|
+
|
|
130
|
+
@Column({ nullable: true })
|
|
131
|
+
@Field({ nullable: true, description: 'When an actuation was last accepted by the peer (ISO 8601 string; portable across DB drivers). Null while unverified.' })
|
|
132
|
+
actuationVerifiedAt?: string
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 마지막 거절 — **코드와 값만 저장한다. 문장은 저장하지 않는다.**
|
|
136
|
+
*
|
|
137
|
+
* `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 그러면 번역이 안 되고, 무엇이
|
|
138
|
+
* 몇 번 났는지 세지도 못하고, 커넥터가 문장을 고치는 날 옛 행과 새 행이 서로 다른 말을 한다.
|
|
139
|
+
* 코드는 `CommandAck.errorCode` 와 같은 낱말이다(ADR-0042 ③) — 표현층이 문장을 만든다.
|
|
140
|
+
*/
|
|
141
|
+
@Column({ type: 'simple-json', nullable: true })
|
|
142
|
+
@Field(type => ScalarObject, { nullable: true, description: 'Why the last actuation was refused: { code, at, params? }. Language-neutral codes only — the presentation layer builds the sentence. Null when the last attempt was accepted or none was made.' })
|
|
143
|
+
actuationFailure?: ActuationFailure
|
|
144
|
+
|
|
98
145
|
/**
|
|
99
146
|
* 마지막 동기에서 **못 옮긴 값의 수**. `null` = 세지 않았다(0 과 다르다).
|
|
100
147
|
*
|
|
@@ -17,6 +17,7 @@ import assert from 'node:assert/strict'
|
|
|
17
17
|
import { CMD, MES_COMMANDS, type CommandTypeSpec } from '@operato/ops-contract'
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
+
SELF_APPROVAL,
|
|
20
21
|
approve,
|
|
21
22
|
dispatch,
|
|
22
23
|
linkApproval,
|
|
@@ -233,6 +234,17 @@ test('★ 두 어휘가 겹치지 않는다 — 겹치면 어느 쪽으로 읽
|
|
|
233
234
|
|
|
234
235
|
/* ── 낸 사람은 자기 것을 승인하지 못한다 ─────────────────────────────────── */
|
|
235
236
|
|
|
237
|
+
test('★ 거절 낱말은 워크리스트와 **같은 글자**여야 한다 — 상수를 보면 이름을 바꿔도 안 걸린다', () => {
|
|
238
|
+
/*
|
|
239
|
+
* 이 줄만 글자를 적는다. 아래 시험들은 `SELF_APPROVAL` 을 읽으므로 **이름을 바꾸면 양쪽이 함께
|
|
240
|
+
* 바뀌어 초록**이다(실제로 심어 보고 알았다). 두 문이 같은 말을 하는지는 **글자**로만 잴 수 있다.
|
|
241
|
+
*
|
|
242
|
+
* 반대쪽은 `worklist/server/controllers/activity-approval/own-request.ts` 다. 거기도 자기 쪽 글자를
|
|
243
|
+
* 못 박는다 — 한쪽이 움직이면 그쪽이 빨개지고, 이 주석이 왜 그런지 말해 준다 (ADR-0052 ③).
|
|
244
|
+
*/
|
|
245
|
+
assert.equal(SELF_APPROVAL, 'self-approval')
|
|
246
|
+
})
|
|
247
|
+
|
|
236
248
|
test('★ 낸 사람이 자기 조치를 승인하지 못한다 — 게이트가 시늉이 되는 자리', async () => {
|
|
237
249
|
/*
|
|
238
250
|
* 이 규칙이 왜 필요한지는 돌려 보고 알았다. 업무 목록은 결재선이 비어 있으면 스레드를 곧바로
|
|
@@ -242,7 +254,11 @@ test('★ 낸 사람이 자기 조치를 승인하지 못한다 — 게이트가
|
|
|
242
254
|
const store = makeStore()
|
|
243
255
|
await propose(store, DOMAIN, SPEC, seed())
|
|
244
256
|
|
|
245
|
-
|
|
257
|
+
/*
|
|
258
|
+
* **문장이 아니라 코드로 잡는다** (ADR-0052 ③). 워크리스트에도 같은 규칙이 서므로 두 문이 같은
|
|
259
|
+
* 낱말을 내야 하고, 문장을 못 박으면 문구를 고치는 날 규칙이 아니라 **문장이** 깨진다.
|
|
260
|
+
*/
|
|
261
|
+
await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), (e: any) => e.code === SELF_APPROVAL)
|
|
246
262
|
assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
|
|
247
263
|
})
|
|
248
264
|
|
|
@@ -253,7 +269,7 @@ test('★ 결재선을 지나온 판정에도 같은 규칙이 걸린다 — Mys
|
|
|
253
269
|
|
|
254
270
|
await assert.rejects(
|
|
255
271
|
settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: PROPOSER, at: AT }),
|
|
256
|
-
|
|
272
|
+
(e: any) => e.code === SELF_APPROVAL
|
|
257
273
|
)
|
|
258
274
|
assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
|
|
259
275
|
})
|
|
@@ -101,16 +101,22 @@ test('★ 구현만 있고 선언이 없으면 — 등록이 말하고, 게이
|
|
|
101
101
|
assert.match(target.detail, /capabilities/, '게이트가 무엇을 넣어야 하는지 말하지 않았다')
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
test('★ 선언만 있고 구현이 없으면 —
|
|
104
|
+
test('★ 선언만 있고 구현이 없으면 — 등록도 게이트도 말한다', () => {
|
|
105
105
|
/*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
106
|
+
* 이 시험은 2026-09-16 까지 「등록은 조용하고, 게이트만 말한다」였다. 그때 주석이 그 침묵을 이렇게
|
|
107
|
+
* 적어 두었다 — 「등록 검사는 이 방향을 보지 않는다. 그래서 **게이트가 말하지 않으면 아무도 말하지
|
|
108
|
+
* 않는다**」. 그 문장이 곧 결함의 서술이었다: 조치를 넘기는 자리까지 가 본 사람만 그 사실을 듣고,
|
|
109
|
+
* 커넥터를 만든 사람은 선언했으니 된 줄 안다.
|
|
110
|
+
*
|
|
111
|
+
* ADR-0036 ③ 개정으로 등록이 양방향을 본다. 게이트의 문장은 그대로다 — 그 사람은 조치를 하겠다고
|
|
112
|
+
* 적어 두었으니 「관측만 냅니다」는 여전히 사실이 아니다.
|
|
109
113
|
*/
|
|
110
114
|
const before = warnings.length
|
|
111
115
|
const type = register({ capabilities: ['live', 'actuate'], openLiveFeed: () => () => {} })
|
|
112
116
|
|
|
113
|
-
|
|
117
|
+
const said = warnings.slice(before).join('\n')
|
|
118
|
+
assert.ok(said.includes('actuate'), '등록이 증명 없는 선언을 말하지 않았다')
|
|
119
|
+
assert.ok(said.includes('declares'), '등록이 어느 방향의 어긋남인지 말하지 않았다')
|
|
114
120
|
assert.deepEqual(capabilitiesOf(getAdapter(type)), ['live'], '능력 판정이 구현 없는 선언을 걸러 내지 않았다')
|
|
115
121
|
|
|
116
122
|
const target = askGate(type, 'plant-a') as any
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **한 번 넘겨 봤나** — 설정이 맞는 것과 저쪽이 받는 것은 다르다 (ADR-0046 ②).
|
|
3
|
+
*
|
|
4
|
+
* 주소도 비밀값도 다 적혀 있는데 저쪽 프로세스가 그 값을 잃은 일이 있었다(§`actuationReadiness`).
|
|
5
|
+
* 그것은 넘겨 봐야만 알고, 그래서 「아직 안 해 봤다」와 「해 봤고 됐다」와 「해 봤고 안 됐다」가
|
|
6
|
+
* 서로 다른 사실이다. 이 시험은 그 셋이 안 뭉개지는지 본다.
|
|
7
|
+
*
|
|
8
|
+
* ── 저장되는 것은 코드다 ────────────────────────────────────────────────────
|
|
9
|
+
* `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 번역도 집계도 안 되고,
|
|
10
|
+
* 커넥터가 문장을 고치는 날 옛 행과 새 행이 다른 말을 한다. 그래서 사유는 코드로만 앉는다.
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
|
|
16
|
+
import { actuationOutcomePatch } from '../server/service/reference/actuation-verification.js'
|
|
17
|
+
|
|
18
|
+
const AT = '2026-09-16T04:05:06.000Z'
|
|
19
|
+
|
|
20
|
+
test('받아들여지면 verified 로 서고 그 시각이 남는다', () => {
|
|
21
|
+
const patch = actuationOutcomePatch({ ok: true, ref: 'JO-1' }, AT)
|
|
22
|
+
|
|
23
|
+
assert.equal(patch.actuationState, 'verified')
|
|
24
|
+
assert.equal(patch.actuationVerifiedAt, AT)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('★ 받아들여지면 지난 거절을 지운다 — 「됐는데 실패 사유가 있다」를 그리지 않게', () => {
|
|
28
|
+
assert.equal(actuationOutcomePatch({ ok: true }, AT).actuationFailure, null)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('★ 거절되면 사유가 코드로 앉는다 — 문장이 아니라', () => {
|
|
32
|
+
const patch = actuationOutcomePatch(
|
|
33
|
+
{ ok: false, error: '저쪽이 503 을 답했습니다', errorCode: 'peer-unavailable', errorParams: { status: 503 } },
|
|
34
|
+
AT
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
assert.equal(patch.actuationState, 'failed')
|
|
38
|
+
assert.deepEqual(patch.actuationFailure, { code: 'peer-unavailable', at: AT, params: { status: 503 } })
|
|
39
|
+
|
|
40
|
+
/* 사람 문장은 어디에도 안 실린다 — 그것이 이 칸을 만든 이유다. */
|
|
41
|
+
assert.ok(!JSON.stringify(patch).includes('503 을 답했습니다'))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('★ 코드가 없으면 사유를 비운다 — 자리를 채우려고 문장을 옮기지 않는다', () => {
|
|
45
|
+
const patch = actuationOutcomePatch({ ok: false, error: '무언가 잘못됐습니다' }, AT)
|
|
46
|
+
|
|
47
|
+
assert.equal(patch.actuationState, 'failed', '코드가 없어도 실패는 실패다')
|
|
48
|
+
assert.equal(patch.actuationFailure, null, '모르는 것을 적었다')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('★ 거절은 성공 시각을 지우지 않는다 — 「한 번이라도 닿은 적 있나」가 남아야 한다', () => {
|
|
52
|
+
const patch = actuationOutcomePatch({ ok: false, errorCode: 'peer-unavailable' }, AT)
|
|
53
|
+
|
|
54
|
+
assert.ok(!('actuationVerifiedAt' in patch), '실패가 지난 성공의 시각을 덮었다')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('★ params 가 없으면 그 칸을 만들지 않는다 — 빈 객체와 「없다」는 다르다', () => {
|
|
58
|
+
const patch = actuationOutcomePatch({ ok: false, errorCode: 'not-declared' }, AT)
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(patch.actuationFailure, { code: 'not-declared', at: AT })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* 위 시험들은 **판단만** 본다. 판단이 옳아도 아무도 안 부르면 레퍼런스에는 아무것도 안 남는다 —
|
|
65
|
+
* 순수 시험은 부르는 사람을 못 본다.
|
|
66
|
+
*
|
|
67
|
+
* 값으로 재려면 DB 가 붙어야 해서, 여기서는 원문을 읽는다. 다만 **어느 함수 안에서 부르는지까지**
|
|
68
|
+
* 본다. 파일 전체에서 이름을 세면 **선언이 자기를 센다** — 실제로 호출을 지우고 돌려 봤는데 이
|
|
69
|
+
* 시험이 초록이었다. 세는 것은 주장이지 그 이웃이 아니다.
|
|
70
|
+
*/
|
|
71
|
+
const strip = (x: string) => x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
|
72
|
+
const src = strip(readFileSync(new URL('../server/service/reference/actuation-routing.ts', import.meta.url), 'utf8'))
|
|
73
|
+
const bodyOf = (name: string) => {
|
|
74
|
+
const at = src.indexOf(`function ${name}(`)
|
|
75
|
+
assert.ok(at > 0, `${name} 이 없다`)
|
|
76
|
+
const next = src.slice(at + 1).search(/\nasync function |\nexport (async )?function /)
|
|
77
|
+
return next < 0 ? src.slice(at) : src.slice(at, at + 1 + next)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test('★ routeActuation 이 실제로 그것을 부른다 — 판단만 맞고 아무도 안 부르면 남는 것이 없다', () => {
|
|
81
|
+
const route = bodyOf('routeActuation')
|
|
82
|
+
|
|
83
|
+
assert.match(route, /recordActuationOutcome\(/, '조치 경로에서 그 기록이 불리지 않는다')
|
|
84
|
+
/* 기록이 본업을 막지 않는다 — 실패해도 조치 결과는 그대로 돌아간다. */
|
|
85
|
+
assert.match(route, /\.catch\(/, '기록이 실패하면 조치까지 함께 넘어진다')
|
|
86
|
+
assert.match(route, /return result/, '조치 결과를 돌려주지 않는다')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('★ 기록하는 자리가 판단을 쓰고 그 값을 저장한다 — 자기 판단을 다시 짓지 않는다', () => {
|
|
90
|
+
const record = bodyOf('recordActuationOutcome')
|
|
91
|
+
|
|
92
|
+
assert.match(record, /actuationOutcomePatch\(/, '판단을 부르지 않는다 — 두 벌이 된다')
|
|
93
|
+
assert.match(record, /\.update\(/, '부른 결과를 저장하지 않는다')
|
|
94
|
+
})
|