@things-factory/headless-twin 10.1.28 → 10.1.29
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/actuation-verification.d.ts +22 -0
- package/dist-server/service/reference/actuation-verification.js +37 -0
- package/dist-server/service/reference/actuation-verification.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +32 -2
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-model/twin-lineage-query.js +14 -0
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +11 -6
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/package.json +7 -7
- package/server/service/reference/actuation-verification.ts +42 -0
- package/server/service/reference/reference-resolver.ts +33 -2
- package/server/service/twin-model/twin-lineage-query.ts +13 -0
- package/server/service/twin-model/twin-model-item-query.ts +33 -6
- package/test/actuation-verification-state.test.ts +46 -1
- package/test/axis-declaration-passthrough-db.test.ts +43 -1
- package/test/lineage-survives-restart.test.ts +15 -0
- package/test/twin-model-item-db.test.ts +18 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReadBasis } from '@operato/ops-contract'
|
|
2
|
+
|
|
1
3
|
import { twinWarn } from '../../engine/log.js'
|
|
2
4
|
import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
3
5
|
|
|
@@ -20,6 +22,26 @@ import { at, idOf, nameIndex, namesFor } from './name-index.js'
|
|
|
20
22
|
/* 커널 런타임 로드 — 축·관계·속성 선언의 SSOT. 이 파일은 도메인 모양을 알지 않는다. */
|
|
21
23
|
const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, documentPath, isCacheableTense, relationsFrom, relationsTo, propertiesOf, splitRelationIds, tenseOf } =
|
|
22
24
|
require('@operato/ops-contract')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 목록의 답 — **근거는 계약이 닫아 둔 다섯 중 하나다**(`READ_BASIS`).
|
|
28
|
+
*
|
|
29
|
+
* 2026-09-18 판정(ADR-0075 결정 3 을 코드에 대어 본 결과): 이 문이 `'rows-scanned'` 를 내고 있었다.
|
|
30
|
+
* 그것은 출처가 아니라 **우리가 어떻게 셌나**이고, 목록 소비자가 근거로 갈래를 나눌 때 계약에 없는
|
|
31
|
+
* 값을 만나게 된다. 출처는 `rows` 이고, 훑어 걸렀다는 사실은 `scanned` · `truncated` 가 말한다.
|
|
32
|
+
* 여기에 칸을 묶어 두면 계약 밖의 값이 다시 생길 때 타입 검사가 잡는다.
|
|
33
|
+
*/
|
|
34
|
+
type AxisListAnswer = {
|
|
35
|
+
axis: string
|
|
36
|
+
label: string
|
|
37
|
+
basis: ReadBasis
|
|
38
|
+
total: number | null
|
|
39
|
+
offset: number
|
|
40
|
+
/** 훑어 센 경우에만 — 몇 줄을 읽었나, 상한에서 잘렸나. */
|
|
41
|
+
scanned?: number
|
|
42
|
+
truncated?: boolean
|
|
43
|
+
[key: string]: unknown
|
|
44
|
+
}
|
|
23
45
|
import { externalIncoming, resolveExternal } from './external-resolver.js'
|
|
24
46
|
|
|
25
47
|
/**
|
|
@@ -131,7 +153,7 @@ export class TwinModelItemQuery {
|
|
|
131
153
|
const repo = getRepository(row.entity)
|
|
132
154
|
const where = { domain: { id: domainId } as any, instance: { id: inst.id } as any }
|
|
133
155
|
const [rows, total] = await repo.findAndCount({ where, order: row.order, skip, take })
|
|
134
|
-
|
|
156
|
+
const answer: AxisListAnswer = {
|
|
135
157
|
axis,
|
|
136
158
|
label: info.label,
|
|
137
159
|
basis: 'rows',
|
|
@@ -141,23 +163,27 @@ export class TwinModelItemQuery {
|
|
|
141
163
|
resourceClassLabels: this.classLabels(inst.model ?? {}),
|
|
142
164
|
items: rows.map((r: any) => this.rowItem(axis, r))
|
|
143
165
|
}
|
|
166
|
+
return answer
|
|
144
167
|
}
|
|
145
168
|
|
|
146
169
|
if (row && q) {
|
|
147
170
|
/*
|
|
148
|
-
* 검색은 아직 DB 로 내리지 않았다. **그 사실을 밝힌다**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
171
|
+
* 검색은 아직 DB 로 내리지 않았다. **그 사실을 밝힌다** — 흉내로 덮으면 규모가 오는 날까지
|
|
172
|
+
* 아무도 이 한계를 모른다. 훑는 건수를 상한으로 막고, 상한에 닿았으면 잘렸다고 말한다
|
|
173
|
+
* (알리지 않고 자르지 않는다).
|
|
174
|
+
*
|
|
175
|
+
* 말하는 자리는 `scanned` · `truncated` 다. 근거(`basis`)는 그대로 `rows` 이고 — 값이 어디서
|
|
176
|
+
* 왔는지는 훑었든 DB 가 골랐든 같은 행이다 — 계약이 닫아 둔 다섯 밖으로 나가지 않는다.
|
|
151
177
|
*/
|
|
152
178
|
const SCAN_MAX = 5000
|
|
153
179
|
const repo = getRepository(row.entity)
|
|
154
180
|
const where = { domain: { id: domainId } as any, instance: { id: inst.id } as any }
|
|
155
181
|
const rows = await repo.find({ where, order: row.order, take: SCAN_MAX })
|
|
156
182
|
const hit = rows.filter((r: any) => matches(this.rowItem(axis, r), q))
|
|
157
|
-
|
|
183
|
+
const answer: AxisListAnswer = {
|
|
158
184
|
axis,
|
|
159
185
|
label: info.label,
|
|
160
|
-
basis: 'rows
|
|
186
|
+
basis: 'rows',
|
|
161
187
|
total: hit.length,
|
|
162
188
|
offset: skip,
|
|
163
189
|
resourceClassLabels: this.classLabels(inst.model ?? {}),
|
|
@@ -168,6 +194,7 @@ export class TwinModelItemQuery {
|
|
|
168
194
|
return { items, names: namesFor(items, nameIndex(inst.model ?? {})) }
|
|
169
195
|
})()
|
|
170
196
|
}
|
|
197
|
+
return answer
|
|
171
198
|
}
|
|
172
199
|
|
|
173
200
|
/*
|
|
@@ -13,7 +13,7 @@ import { test } from 'node:test'
|
|
|
13
13
|
import assert from 'node:assert/strict'
|
|
14
14
|
import { readFileSync } from 'node:fs'
|
|
15
15
|
|
|
16
|
-
import { actuationOutcomePatch } from '../server/service/reference/actuation-verification.js'
|
|
16
|
+
import { actuationAddressed, actuationOutcomePatch, actuationResetPatch } from '../server/service/reference/actuation-verification.js'
|
|
17
17
|
|
|
18
18
|
const AT = '2026-09-16T04:05:06.000Z'
|
|
19
19
|
|
|
@@ -92,3 +92,48 @@ test('★ 기록하는 자리가 판단을 쓰고 그 값을 저장한다 —
|
|
|
92
92
|
assert.match(record, /actuationOutcomePatch\(/, '판단을 부르지 않는다 — 두 벌이 된다')
|
|
93
93
|
assert.match(record, /\.update\(/, '부른 결과를 저장하지 않는다')
|
|
94
94
|
})
|
|
95
|
+
|
|
96
|
+
test('★ 주소를 바꾸는 저장은 검증을 되돌린다 — 지난 왕복은 지난 주소의 이야기다', () => {
|
|
97
|
+
/*
|
|
98
|
+
* 2026-09-18 실측(ADR-0046 ② 를 코드에 대어 본 결과): 칸 셋도 왕복의 결과를 적는 자리도 섰는데,
|
|
99
|
+
* `verified` 인 연결의 주소를 바꿔도 그대로 `verified` 였다. 화면은 다른 공장을 가리키는 연결을
|
|
100
|
+
* 「확인됨」으로 그린다.
|
|
101
|
+
*/
|
|
102
|
+
const before = { intakeEndpoint: 'https://a.example/intake', intakeSiteId: 'line-a', intakeSecret: 's1' }
|
|
103
|
+
const moved = actuationResetPatch(before, { ...before, intakeEndpoint: 'https://b.example/intake' })
|
|
104
|
+
assert.deepEqual(moved, { actuationState: 'unverified', actuationVerifiedAt: null, actuationFailure: null })
|
|
105
|
+
assert.ok(actuationResetPatch(before, { ...before, intakeSiteId: 'line-b' }), '받는 사이트가 바뀌었다')
|
|
106
|
+
assert.ok(actuationResetPatch(before, { ...before, intakeSecret: 's2' }), '저쪽이 우리를 못 알아보면 그 왕복은 다시 성립하지 않는다')
|
|
107
|
+
assert.ok(actuationResetPatch(undefined, before), '처음 적는 주소도 아직 왕복이 없다')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('주소가 그대로면 검증도 그대로 — 이름만 고쳤다고 되돌리지 않는다', () => {
|
|
111
|
+
const cfg = { intakeEndpoint: 'https://a.example/intake', intakeSiteId: 'line-a', intakeSecret: 's1' }
|
|
112
|
+
assert.equal(actuationResetPatch(cfg, { ...cfg, label: '군산 1라인' }), null)
|
|
113
|
+
/* 끝의 빗금은 같은 주소다 — 그것으로 되돌리면 저장할 때마다 초록이 사라진다. */
|
|
114
|
+
assert.equal(actuationResetPatch(cfg, { ...cfg, intakeEndpoint: 'https://a.example/intake/' }), null)
|
|
115
|
+
/* 가려진 비밀값은 저장 쪽이 병합해 옛 값 그대로 온다(그래서 여기까지 같은 값이 온다). */
|
|
116
|
+
assert.equal(actuationResetPatch(cfg, { ...cfg }), null)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('★ 보낼 주소가 적힌 연결에만 검증 상태를 말한다 — 읽기만 하는 연결에 할 일을 만들지 않는다', () => {
|
|
120
|
+
assert.equal(actuationAddressed({ intakeEndpoint: 'https://a.example/intake', intakeSiteId: 'line-a' }), true)
|
|
121
|
+
assert.equal(actuationAddressed({ intakeEndpoint: 'https://a.example/intake', siteId: 'line-a' }), true, 'siteId 가 그 자리를 대신한다')
|
|
122
|
+
assert.equal(actuationAddressed({ intakeSiteId: 'line-a' }), false, '주소가 없으면 아무 데도 안 간다')
|
|
123
|
+
assert.equal(actuationAddressed({ intakeEndpoint: 'https://a.example/intake' }), false, '받는 사이트를 모르면 못 보낸다')
|
|
124
|
+
assert.equal(actuationAddressed(undefined), false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('★ 읽기 문이 그 상태를 싣는다 — 저장돼 있어도 나르지 않으면 화면이 볼 길이 없다', () => {
|
|
128
|
+
/*
|
|
129
|
+
* 2026-09-18 실측: 칸 셋은 2026-09 부터 서 있었고 왕복의 결과도 적히는데, 목록도 상세도 그 값을
|
|
130
|
+
* 싣지 않았다. 저장된 사실이 아무에게도 닿지 않는 상태였다.
|
|
131
|
+
*/
|
|
132
|
+
const src = readFileSync(new URL('../server/service/reference/reference-resolver.ts', import.meta.url).pathname, 'utf-8')
|
|
133
|
+
for (const door of ['async twinReferences(', 'async twinReference(']) {
|
|
134
|
+
const at = src.indexOf(door)
|
|
135
|
+
assert.ok(at > 0, `${door} 를 못 찾았다 — 읽는 쪽이 깨졌다`)
|
|
136
|
+
const body = src.slice(at, at + 3000)
|
|
137
|
+
assert.ok(/actuationState/.test(body), `${door} 가 검증 상태를 싣지 않는다`)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
@@ -43,13 +43,21 @@ before(async () => {
|
|
|
43
43
|
q = new TwinModelItemQuery()
|
|
44
44
|
ctx = { state: { domain: { id: d.id } } }
|
|
45
45
|
|
|
46
|
+
/*
|
|
47
|
+
* 축마다 **자기 모양대로** 한 항목씩 — 설명 칸이 축마다 다른 이름으로 온다는 것이 아래 시험의 요점이다.
|
|
48
|
+
* (등급 넷과 검사 명세는 `name` 으로 들어오는데 형 선언은 `description` 을 말한다 — 실측, 2026-09-18.)
|
|
49
|
+
*/
|
|
46
50
|
await TwinEngine.ingestMaster(d.id, {
|
|
47
51
|
source: INSTANCE,
|
|
48
52
|
system: 'wms',
|
|
49
53
|
siteName: 'declaration site',
|
|
50
54
|
space: { width: 40, depth: 20, unit: 'm' },
|
|
51
55
|
locations: [{ id: 'bin-1', type: 'storage', role: 'location', name: 'Bin 1', capacity: 10 }],
|
|
52
|
-
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'bin-1' }]
|
|
56
|
+
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'bin-1' }],
|
|
57
|
+
equipmentClasses: [{ id: 'EC-FORKLIFT', name: '지게차' }],
|
|
58
|
+
materialClasses: [{ id: 'MC-BOX', name: '상자류' }],
|
|
59
|
+
materialDefinitions: [{ id: 'MD-BOX-01', name: '표준 상자', materialClassIds: ['MC-BOX'] }],
|
|
60
|
+
testSpecifications: [{ id: 'TS-DROP', name: '낙하 시험', version: '1', criteria: [] }]
|
|
53
61
|
}, 'reset')
|
|
54
62
|
})
|
|
55
63
|
|
|
@@ -108,3 +116,37 @@ test('★ 있는 개체는 그대로 답한다 — 위의 고침이 다 지워
|
|
|
108
116
|
assert.ok(item, '있는 개체가 사라졌다')
|
|
109
117
|
assert.equal(item.itemId, 'bin-1')
|
|
110
118
|
})
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* **선언이 고른 칸이 실제 항목에 있는가** (2026-09-18).
|
|
122
|
+
*
|
|
123
|
+
* `describedBy` 를 형 선언에서 뽑아 적었더니 여섯 축이 빗나갔다 — 등급 넷 · 품목 정의 · 검사 명세는
|
|
124
|
+
* 형이 `description` 을 말하는데 들어오는 자료는 `name` 을 든다. 화면은 오류 없이 **밑줄이 없는
|
|
125
|
+
* 제목**만 그렸다(도는 트윈에서 실측). 계약과 자료가 갈린 것을 시험이 못 잡았던 이유는, 그 시험이
|
|
126
|
+
* 「그 이름이 계약 어딘가에 선언돼 있나」까지만 봤기 때문이다.
|
|
127
|
+
*
|
|
128
|
+
* 그래서 여기서는 **들어온 항목**에 대고 잰다: 선언한 칸 중 적어도 하나가 그 항목에 값으로 있어야 한다.
|
|
129
|
+
*/
|
|
130
|
+
test('★ 선언이 고른 칸 중 하나는 실제 항목이 들고 있다 — 계약과 자료가 갈리면 밑줄이 사라진다', async () => {
|
|
131
|
+
const sample: Record<string, string> = {
|
|
132
|
+
locations: 'bin-1',
|
|
133
|
+
equipment: 'fk-1',
|
|
134
|
+
equipmentClasses: 'EC-FORKLIFT',
|
|
135
|
+
materialClasses: 'MC-BOX',
|
|
136
|
+
materialDefinitions: 'MD-BOX-01',
|
|
137
|
+
testSpecifications: 'TS-DROP'
|
|
138
|
+
}
|
|
139
|
+
const missing: string[] = []
|
|
140
|
+
for (const [axis, itemId] of Object.entries(sample)) {
|
|
141
|
+
const item = await q.twinModelItem(INSTANCE, axis, itemId, ctx)
|
|
142
|
+
assert.ok(item, `${axis}: 픽스처가 안 들어갔다 — 이 시험이 아무것도 재지 못한다`)
|
|
143
|
+
const keys: string[] = item.identity?.describedBy ?? []
|
|
144
|
+
assert.ok(keys.length, `${axis}: 계약이 설명 칸을 말하지 않는다`)
|
|
145
|
+
const has = keys.some(k => {
|
|
146
|
+
const v = (item.observed ?? {})[k] ?? (item.declared ?? {})[k]
|
|
147
|
+
return v != null && v !== ''
|
|
148
|
+
})
|
|
149
|
+
if (!has) missing.push(`${axis}: 선언은 [${keys.join(', ')}] 인데 항목이 든 것은 [${Object.keys(item.declared ?? {}).join(', ')}]`)
|
|
150
|
+
}
|
|
151
|
+
assert.deepEqual(missing, [], missing.join('\n'))
|
|
152
|
+
})
|
|
@@ -209,6 +209,21 @@ test('계보를 거슬러 올라가 선언과 나란히 놓는다 — 그 차이
|
|
|
209
209
|
assert.equal(steel.matches, true)
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
+
test('★ 만든 것을 관측하지 못했으면 「다르다」고 하지 않는다 — 안 본 것과 어긋난 것은 다른 사실이다', async () => {
|
|
213
|
+
const q = new TwinLineageQuery()
|
|
214
|
+
const ctx = { state: { domain: { id: domainId } } } as any
|
|
215
|
+
/*
|
|
216
|
+
* 완제품 클래스의 개체이지만 저널에 그것을 낸 변환이 없다 — 자재로도 이어지고 레시피도 하나다.
|
|
217
|
+
* 예전에는 여기서 셈이 그대로 돌아 관측을 전부 0 으로 적고 「레시피와 다릅니다」를 냈다
|
|
218
|
+
* (2026-09-18 :3000 에서 카메라 로트 23 줄이 그렇게 보였다).
|
|
219
|
+
*/
|
|
220
|
+
const lin = await q.twinItemLineage(INSTANCE, `urn:epc:id:sgtin:${CP}.078101.999999`, ctx)
|
|
221
|
+
assert.equal(lin.conformance.comparable, false, `관측이 없는데 견주었다: ${JSON.stringify(lin.conformance?.lines?.slice(0, 2))}`)
|
|
222
|
+
assert.equal(lin.conformance.reason, 'made-not-observed')
|
|
223
|
+
assert.ok(lin.conformance.recipe, '어느 레시피와 견주려 했는지는 말한다 — 사람이 다음 걸음을 안다')
|
|
224
|
+
assert.equal(lin.basis.transformationsRead >= 0, true)
|
|
225
|
+
})
|
|
226
|
+
|
|
212
227
|
test('바인딩이 없으면 비교하지 않고 이유를 알린다 — 짐작으로 이으면 거짓 진단이 된다', async () => {
|
|
213
228
|
const q = new TwinLineageQuery()
|
|
214
229
|
const ctx = { state: { domain: { id: domainId } } } as any
|
|
@@ -114,12 +114,29 @@ test('아직 문서에 사는 축도 열린다 — 이주 전이라고 안 보
|
|
|
114
114
|
|
|
115
115
|
test('검색이 전수 조회라는 사실을 드러낸다 — 감추면 한계를 아무도 모른다', async () => {
|
|
116
116
|
const r = await q.twinModelEntities(INSTANCE, 'equipment', ctx, 'weld')
|
|
117
|
-
assert.equal(r.basis, 'rows-scanned', '아직 DB 로 내리지 않았다는 사실이 응답에 있다')
|
|
118
117
|
assert.equal(r.total, 2)
|
|
118
|
+
/* 훑어 걸렀다는 사실은 **이 두 칸이** 말한다(근거가 아니라 셈의 한계다). */
|
|
119
|
+
assert.equal(typeof r.scanned, 'number', '몇 줄을 읽었는지 말하지 않는다')
|
|
119
120
|
assert.equal(r.truncated, false, '상한에 닿지 않았으면 잘리지 않았다고 말한다')
|
|
120
121
|
assert.deepEqual(r.items.map((i: any) => i.id).sort(), ['welder-1', 'welder-2'])
|
|
121
122
|
})
|
|
122
123
|
|
|
124
|
+
test('★ 근거는 계약이 닫아 둔 다섯 안에서만 답한다', async () => {
|
|
125
|
+
/*
|
|
126
|
+
* 2026-09-18 판정(ADR-0075 결정 3 을 코드에 대어 본 결과): 검색 갈래가 `'rows-scanned'` 를 냈다.
|
|
127
|
+
* 그것은 출처가 아니라 우리가 어떻게 셌나이고, 목록 소비자는 계약에 없는 값을 만난다. 출처는
|
|
128
|
+
* 훑었든 DB 가 골랐든 같은 행이므로 `rows` 다.
|
|
129
|
+
*/
|
|
130
|
+
const { READ_BASIS } = req('@operato/ops-contract')
|
|
131
|
+
const answers = [
|
|
132
|
+
await q.twinModelEntities(INSTANCE, 'equipment', ctx, 'weld'),
|
|
133
|
+
await q.twinModelEntities(INSTANCE, 'equipment', ctx),
|
|
134
|
+
await q.twinModelEntities(INSTANCE, 'recipes', ctx)
|
|
135
|
+
]
|
|
136
|
+
for (const a of answers) assert.ok(READ_BASIS.includes(a.basis), `계약 밖의 근거 '${a.basis}'`)
|
|
137
|
+
assert.equal(answers[0].basis, 'rows', '훑어 세도 값이 온 곳은 행이다')
|
|
138
|
+
})
|
|
139
|
+
|
|
123
140
|
test('모르는 축은 거절한다 — 빈 목록은 "없다" 로 읽힌다', async () => {
|
|
124
141
|
await assert.rejects(() => q.twinModelEntities(INSTANCE, 'nosuch', ctx), /unknown concept axis/)
|
|
125
142
|
await assert.rejects(() => q.twinModelItem(INSTANCE, 'nosuch', 'x', ctx), /unknown concept axis/)
|