@things-factory/headless-twin 10.1.3 → 10.1.5
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/engine/canonical-ingest.js +48 -3
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/ingest-dedupe.d.ts +1 -50
- package/dist-server/engine/ingest-dedupe.js +24 -327
- package/dist-server/engine/ingest-dedupe.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +1 -550
- package/dist-server/engine/ingest-health.js +20 -663
- package/dist-server/engine/ingest-health.js.map +1 -1
- package/dist-server/engine/twin-engine.js +14 -2
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/service/reference/twin-reference.d.ts +24 -0
- package/dist-server/service/reference/twin-reference.js +4 -3
- package/dist-server/service/reference/twin-reference.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +21 -1
- package/dist-server/service/twin-event/twin-event-keys.js +29 -2
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +35 -0
- package/dist-server/service/twin-event/twin-event.js +5 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/package.json +8 -7
- package/server/engine/canonical-ingest.ts +52 -3
- package/server/engine/ingest-dedupe.ts +22 -323
- package/server/engine/ingest-health.ts +18 -1075
- package/server/engine/twin-engine.ts +14 -2
- package/server/service/reference/twin-reference.ts +29 -4
- package/server/service/twin-event/twin-event-keys.ts +31 -2
- package/server/service/twin-event/twin-event.ts +38 -0
- package/test/control-capability.test.ts +6 -11
- package/test/dispatch-after-approval-source.test.ts +81 -0
- package/test/guard-roots.ts +76 -0
- package/test/ingest-health-wiring.test.ts +3 -4
- package/test/journal-read-discipline.test.ts +3 -4
- package/test/kernel-kind-guard.test.ts +3 -1
- package/test/provenance-promotion.test.ts +99 -0
- package/test/rejection-carries-event-id.test.ts +101 -0
- package/test/secrets-at-rest.test.ts +135 -0
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1013,9 +1013,21 @@ export class TwinEngine {
|
|
|
1013
1013
|
this.pruneJournal(domainId).catch(err => twinError('[twin-engine] journal prune failed', err?.message ?? err))
|
|
1014
1014
|
}, this.RETENTION_SWEEP_MS)
|
|
1015
1015
|
if (typeof this.retentionTimer?.unref === 'function') this.retentionTimer.unref()
|
|
1016
|
+
/*
|
|
1017
|
+
* 두 상태를 **다른 문장으로** 말한다.
|
|
1018
|
+
*
|
|
1019
|
+
* 앞서는 한 문장에 `${일수 ?? '(도메인 정책)'}일` 로 끼워 넣었고, 기본값이 없는 배포에서
|
|
1020
|
+
* 「(도메인 정책)일」이 찍혔다(2026-09-09 실측). 뜻이 없는 문장이고, 읽는 사람이 **며칠인지
|
|
1021
|
+
* 적혀 있는데 못 읽는 것**으로 오해한다.
|
|
1022
|
+
*
|
|
1023
|
+
* 수가 들어갈 자리에 수가 아닌 것을 끼우면 그 자리는 값이 아니라 구멍이다. 상태가 둘이면
|
|
1024
|
+
* 문장도 둘이다.
|
|
1025
|
+
*/
|
|
1016
1026
|
twinLog(
|
|
1017
|
-
|
|
1018
|
-
|
|
1027
|
+
this.JOURNAL_RETENTION_DAYS
|
|
1028
|
+
? `[twin-engine] journal retention on — ${this.JOURNAL_RETENTION_DAYS}일 · ${this.RETENTION_SWEEP_MS / 60000}분마다`
|
|
1029
|
+
: `[twin-engine] journal retention on — 보존 기간은 도메인마다 정한다(프로세스 기본값 없음) · ` +
|
|
1030
|
+
`${this.RETENTION_SWEEP_MS / 60000}분마다`
|
|
1019
1031
|
)
|
|
1020
1032
|
}
|
|
1021
1033
|
|
|
@@ -1,13 +1,14 @@
|
|
|
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 { Domain, ScalarObject } from '@things-factory/shell'
|
|
4
|
+
import { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
* TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018).
|
|
8
8
|
* 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티.
|
|
9
9
|
* inbound 전용(구조·상태). 아웃바운드 액추에이션은 command-routing(ActuationAdapter) 소유.
|
|
10
|
-
*
|
|
10
|
+
* mappingSpec/scopeSpec 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통).
|
|
11
|
+
* connectionConfig 는 자격 증명을 담으므로 암호화되는 텍스트 칸이다(칸 주석 참조).
|
|
11
12
|
* 설계 SoT: operato-twin/design/plans/reference-management.md.
|
|
12
13
|
* (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
|
|
13
14
|
*/
|
|
@@ -46,8 +47,32 @@ export class TwinReference {
|
|
|
46
47
|
@Field({ description: "Adapter type registry key (e.g. 'virtual' | 'rest' | 'db' | 'epcis')." })
|
|
47
48
|
adapterType: string
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* 이 연결에 필요한 값 — **자격 증명이 여기 들어온다. 그래서 저장될 때 암호화된다.**
|
|
52
|
+
*
|
|
53
|
+
* ── 왜 표에 두나 (2026-09-09) ─────────────────────────────────────────────
|
|
54
|
+
* 비밀값은 환경에 두고 이름으로 고르는 것이 원칙이다(§`webhookSecretCandidates`). 그 원칙은
|
|
55
|
+
* **설치본마다 하나인 키**에 맞는다 — 배포할 때 넣으면 된다.
|
|
56
|
+
*
|
|
57
|
+
* 트윈의 연결은 그 모양이 아니다. **사람이 화면에서 연결을 만든다.** 새 공장을 붙이려고 재배포할
|
|
58
|
+
* 수는 없으므로, 연결마다 다른 이 값은 표에 있어야 한다. 그 자리에서 원칙이 갈린다:
|
|
59
|
+
*
|
|
60
|
+
* 설치본마다 하나인 키 환경변수. 이름이 어느 상대 것인지 말한다
|
|
61
|
+
* 연결마다 다른 값 표. 대신 저장될 때 암호화한다
|
|
62
|
+
*
|
|
63
|
+
* ── 무엇을 지키고 무엇을 못 지키나 ────────────────────────────────────────
|
|
64
|
+
* 저장된 것을 지킨다 — DB 파일 · 백업 · 복제본. 2026-09-09 에 이 칸에서 `hookSecret` 과 접속
|
|
65
|
+
* 토큰을 명령 두 개로 읽었다.
|
|
66
|
+
*
|
|
67
|
+
* **프로세스를 돌릴 수 있는 사람에게서는 못 지킨다** — 그 사람은 키를 갖고 있다. 그리고 값이
|
|
68
|
+
* 화면으로 나가는 것도 이 칸이 막지 않는다: 내보내는 쪽이 어댑터 스키마의 `secret: true` 를 보고
|
|
69
|
+
* 가린다(§`reference-resolver` 의 상세 조회). **두 가지가 갈려 있으므로 한쪽만 하면 반쪽이다.**
|
|
70
|
+
*
|
|
71
|
+
* 기존 평문 행은 안 깨진다 — 변환기가 암호문 모양이 아닌 값을 알아보고 파싱하며 경고를 남기고,
|
|
72
|
+
* 다시 저장될 때 암호화된다.
|
|
73
|
+
*/
|
|
74
|
+
@Column({ nullable: true, ...encryptedJsonColumn() })
|
|
75
|
+
@Field(type => ScalarObject, { nullable: true, description: 'Adapter-specific connection config (endpoint, credentials, params). Encrypted at rest; secret-declared fields are masked on the way out.' })
|
|
51
76
|
connectionConfig?: any
|
|
52
77
|
|
|
53
78
|
@Column({ type: 'simple-json', nullable: true })
|
|
@@ -26,6 +26,8 @@ export interface TwinEventKeys {
|
|
|
26
26
|
bizTransaction?: string
|
|
27
27
|
locationId?: string
|
|
28
28
|
moverId?: string
|
|
29
|
+
/** `'actual'`, `'simulated'`, or absent — absent means not declared, which is not `actual`. */
|
|
30
|
+
provenance?: string
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/*
|
|
@@ -168,7 +170,33 @@ export function actionOf(envelope: any): string | undefined {
|
|
|
168
170
|
return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* **Whether the sender declared this fact as having actually happened** — the envelope's
|
|
175
|
+
* `provenance`, promoted to a column.
|
|
176
|
+
*
|
|
177
|
+
* ── Why it is an indexed column (2026-09-09) ──────────────────────────────
|
|
178
|
+
* The journal held 1,134 rows, all of them generator output, and there was nowhere to ask about
|
|
179
|
+
* it. While the declaration sits inside `payload`, none of the five drivers can answer "count
|
|
180
|
+
* these without the invented ones" — the same reason `action` was promoted.
|
|
181
|
+
*
|
|
182
|
+
* ── Only the two declared values are taken ────────────────────────────────
|
|
183
|
+
* Anything that is not `actual` or `simulated` becomes `undefined`. A source's stray value does
|
|
184
|
+
* not reach the index (the discipline in `actionOf`).
|
|
185
|
+
*
|
|
186
|
+
* **`undefined` means "not declared", not "it happened".** A reader has to distinguish three
|
|
187
|
+
* states — declared simulated, declared actual, not declared. Folding the last two together
|
|
188
|
+
* means a generator that omits the field has its output counted as production.
|
|
189
|
+
*/
|
|
190
|
+
export function provenanceOf(envelope: any): string | undefined {
|
|
191
|
+
/*
|
|
192
|
+
* Read from the envelope only. `data` is where the source system writes, and a source that
|
|
193
|
+
* could declare its own facts actual would make this no longer the connecting side's statement.
|
|
194
|
+
*/
|
|
195
|
+
const p = envelope?.provenance
|
|
196
|
+
return p === 'actual' || p === 'simulated' ? p : undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Every promoted key for one event — the write path calls this function and nothing else. */
|
|
172
200
|
export function twinEventKeys(envelope: any): TwinEventKeys {
|
|
173
201
|
return {
|
|
174
202
|
action: actionOf(envelope),
|
|
@@ -177,6 +205,7 @@ export function twinEventKeys(envelope: any): TwinEventKeys {
|
|
|
177
205
|
orderId: clip(orderOf(envelope), 'orderId'),
|
|
178
206
|
bizTransaction: clip(bizTransactionOf(envelope), 'bizTransaction'),
|
|
179
207
|
locationId: clip(locationOf(envelope), 'locationId'),
|
|
180
|
-
moverId: clip(equipmentIdOf(envelope), 'moverId')
|
|
208
|
+
moverId: clip(equipmentIdOf(envelope), 'moverId'),
|
|
209
|
+
provenance: provenanceOf(envelope)
|
|
181
210
|
}
|
|
182
211
|
}
|
|
@@ -202,6 +202,44 @@ export class TwinEvent {
|
|
|
202
202
|
@Field({ nullable: true, description: 'Equipment identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server. The column keeps the legacy name `moverId`: the journal is append-only history and mixing two names for the same fact across time is worse than an outdated name. Renaming belongs with event-schema versioning.' })
|
|
203
203
|
moverId?: string
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* **Whether the sender declared this fact as having actually happened** — the envelope's
|
|
207
|
+
* `provenance`, promoted here.
|
|
208
|
+
*
|
|
209
|
+
* ── Why a column (measured 2026-09-09) ──────────────────────────────────
|
|
210
|
+
* This table held 1,134 rows, all of them generator output, and **there was nowhere to ask
|
|
211
|
+
* about it**:
|
|
212
|
+
*
|
|
213
|
+
* ```
|
|
214
|
+
* select count(*) from twin_events 1134
|
|
215
|
+
* select count(*) where payload like '%SIM-%' 1134
|
|
216
|
+
* select count(*) where order_id like 'WO-SIM-%' 558 <- half of them
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* The only signal was the sender's naming convention (`SIM-`), and the column that convention
|
|
220
|
+
* reaches was empty on half the rows. While it sits inside `payload`, none of the five drivers
|
|
221
|
+
* can answer "count these without the invented ones" — the same reason `action` was promoted.
|
|
222
|
+
*
|
|
223
|
+
* ── Three states, kept apart ────────────────────────────────────────────
|
|
224
|
+
* ```
|
|
225
|
+
* 'simulated' the sender said it was invented
|
|
226
|
+
* 'actual' the sender said it happened
|
|
227
|
+
* null **not declared** — which is not `actual`
|
|
228
|
+
* ```
|
|
229
|
+
*
|
|
230
|
+
* Folding the last two together means a generator that omits the declaration has its output
|
|
231
|
+
* counted as production, and nothing says so. Hence no default: an absent declaration is not
|
|
232
|
+
* disguised as a valid-looking value (the discipline of `structureRev` and `lastWarningCount`).
|
|
233
|
+
*
|
|
234
|
+
* ── No index ────────────────────────────────────────────────────────────
|
|
235
|
+
* Three possible values is low cardinality within one twin — the judgement already made for
|
|
236
|
+
* `bizStep` and `locationId`. Performance folds arrive already narrowed by domain, instance and
|
|
237
|
+
* time, so filtering after that is cheap.
|
|
238
|
+
*/
|
|
239
|
+
@Column({ length: 16, nullable: true })
|
|
240
|
+
@Field({ nullable: true, description: 'Whether the sending side declared this fact as actually having happened. Either "actual" or "simulated"; null means it was not declared, which is not the same as actual — a generator that omits it must not be counted as production.' })
|
|
241
|
+
provenance?: string
|
|
242
|
+
|
|
205
243
|
@Column({ type: 'simple-json', nullable: true })
|
|
206
244
|
@Field(type => ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' })
|
|
207
245
|
payload?: any
|
|
@@ -90,14 +90,9 @@ test('라우터가 선언을 본다 — 없으면 사유 코드로 거절한다'
|
|
|
90
90
|
assert.match(src, /'no-source'/, '원본을 모르는 트윈은 구동 대상이 아니다')
|
|
91
91
|
})
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
assert.match(src, /capabilities: \[[^\]]*'control'[^\]]*\]/, '구동 능력을 선언한다')
|
|
100
|
-
assert.match(src, /capabilities: \[[^\]]*'live'[^\]]*\]/, '피드 능력도 선언한다(§4)')
|
|
101
|
-
assert.match(src, /cannot re-seed itself yet/, '없는 기능을 있는 것처럼 답하지 않는다')
|
|
102
|
-
assert.match(src, /not-running/, '아직 서지 않은 트윈에는 자극을 지어 싣지 않는다')
|
|
103
|
-
})
|
|
93
|
+
/*
|
|
94
|
+
* The assertion that read `operato-twin/server/board/virtual-adapter.ts` moved to that
|
|
95
|
+
* package's own tests when the applications became their own repository — a guard reading a
|
|
96
|
+
* consumer's source has to live where that source lives, or it scans nothing and says green.
|
|
97
|
+
* See operato-application/packages/operato-twin/test/virtual-adapter-capability.test.ts.
|
|
98
|
+
*/
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Two properties of the dispatch that follows an approval, guarded by reading its source.
|
|
3
|
+
*
|
|
4
|
+
* ── Why source and not behaviour ───────────────────────────────────────────
|
|
5
|
+
* `dispatchAfterApproval` runs outside the request, touches the database and reaches an
|
|
6
|
+
* adapter that talks to a factory. It cannot be exercised here. What can be guarded is the
|
|
7
|
+
* shape of the two decisions that make it safe, and both are visible in the text.
|
|
8
|
+
*
|
|
9
|
+
* ── Why the guard lives here now (2026-09-08) ──────────────────────────────
|
|
10
|
+
* These two assertions were in `operato-twin/test/actuation-gate-walk.test.ts`, reading
|
|
11
|
+
* `../../headless-twin/server/…`. The applications then moved to the operato-application
|
|
12
|
+
* repository and that relative path left this repository — three checks failed with ENOENT.
|
|
13
|
+
*
|
|
14
|
+
* The subject of both is a file in *this* package, so this is where they belong. The half that
|
|
15
|
+
* guards the app's own callback (that approval, and only approval, calls dispatch) stays with
|
|
16
|
+
* the app. A guard reads its own repository; when it has to read across one, the seam is in
|
|
17
|
+
* the wrong place.
|
|
18
|
+
*/
|
|
19
|
+
import { test } from 'node:test'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
|
|
23
|
+
import { guardFile } from './guard-roots.js'
|
|
24
|
+
|
|
25
|
+
const PLACE = '../server/service/actuation/dispatch-after-approval.ts'
|
|
26
|
+
|
|
27
|
+
/** The source with comments removed — a term inside a comment is not a use of it. */
|
|
28
|
+
function sourceOf(): string {
|
|
29
|
+
return readFileSync(guardFile(import.meta.url, PLACE), 'utf8')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withoutComments(src: string): string {
|
|
33
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('dispatch does not run inside the approval transaction', () => {
|
|
37
|
+
/*
|
|
38
|
+
* Approval happens inside the worklist's transaction. Sending a request to the factory from
|
|
39
|
+
* inside it means a rollback cannot take the request back: the plant keeps the work order
|
|
40
|
+
* and our ledger has the approval undone. What went out over the network was never in the
|
|
41
|
+
* transaction's care.
|
|
42
|
+
*
|
|
43
|
+
* A transaction-bound store is passed the transaction — `databaseCommandStore(tx)`. An
|
|
44
|
+
* unbound one takes no argument. So the argument list is the property.
|
|
45
|
+
*/
|
|
46
|
+
const src = withoutComments(sourceOf())
|
|
47
|
+
|
|
48
|
+
assert.match(src, /databaseCommandStore\(\)/, 'dispatch uses a transaction-bound store')
|
|
49
|
+
assert.doesNotMatch(src, /databaseCommandStore\(\s*[a-zA-Z]/, 'dispatch is being handed a transaction store')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('a failed dispatch does not undo the approval, and does not take the process down', () => {
|
|
53
|
+
/*
|
|
54
|
+
* This runs outside the request, in `setImmediate`. A throw there is an unhandled rejection
|
|
55
|
+
* and the server exits — one undelivered actuation would stop everything else.
|
|
56
|
+
*
|
|
57
|
+
* The approval also has to survive an adapter that is down. That is the whole reason
|
|
58
|
+
* approving and dispatching are two steps, and it is a measured property, not a wish:
|
|
59
|
+
* command `67f8765a` was dispatched successfully later, on the same approval.
|
|
60
|
+
*/
|
|
61
|
+
const src = sourceOf()
|
|
62
|
+
|
|
63
|
+
assert.match(src, /catch\s*\(/, 'nothing catches a failed dispatch')
|
|
64
|
+
assert.doesNotMatch(
|
|
65
|
+
withoutComments(src),
|
|
66
|
+
/\bthrow\b/,
|
|
67
|
+
'the dispatch path throws — outside a request that ends the process'
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('this guard reads the real file, so a wrong path is red and not green', () => {
|
|
72
|
+
/*
|
|
73
|
+
* The two checks above fail when a pattern stops matching. They would also "pass" against an
|
|
74
|
+
* empty string, so the path itself has to be guarded — that is exactly how these assertions
|
|
75
|
+
* left this repository unnoticed in the first place.
|
|
76
|
+
*/
|
|
77
|
+
const src = sourceOf()
|
|
78
|
+
|
|
79
|
+
assert.ok(src.length > 1000, `the dispatch source is ${src.length} chars — the path is wrong`)
|
|
80
|
+
assert.match(src, /dispatchAfterApproval/, 'the file read is not the one this guard names')
|
|
81
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* Where the source-reading guards look, and what happens when a place is gone.
|
|
7
|
+
*
|
|
8
|
+
* ── Why this exists (2026-09-08) ───────────────────────────────────────────
|
|
9
|
+
* Four guards in this package read the *consumer's* source as well as their own, because the
|
|
10
|
+
* defects they catch are committed by callers: a caller that fills a kernel kind with a
|
|
11
|
+
* literal, a caller that reads the journal without saying it is folding, a caller that
|
|
12
|
+
* ingests without writing to the ledger.
|
|
13
|
+
*
|
|
14
|
+
* The applications then moved to their own repository, and `../../operato-twin/server` stopped
|
|
15
|
+
* existing. Two of the four failed with ENOENT. **The other two passed** — their directory
|
|
16
|
+
* walker swallows a missing root:
|
|
17
|
+
*
|
|
18
|
+
* try { entries = readdirSync(dir) } catch { … }
|
|
19
|
+
*
|
|
20
|
+
* So half of each of those guards was scanning nothing and reporting green. A guard that
|
|
21
|
+
* quietly stops guarding is worse than one that fails, because the failure is the only signal
|
|
22
|
+
* that it was ever load-bearing.
|
|
23
|
+
*
|
|
24
|
+
* ── So a missing root is an error, not a skip ──────────────────────────────
|
|
25
|
+
* `guardRoots` refuses to hand back a root that is not there. The message says which place is
|
|
26
|
+
* gone and what to do about it, because the answer is not "delete the root" — the code being
|
|
27
|
+
* guarded still exists, it is just in another repository now, and the guard has to follow it
|
|
28
|
+
* there.
|
|
29
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the places a guard scans, and fail loudly if any of them is missing.
|
|
34
|
+
*
|
|
35
|
+
* @param base `import.meta.url` of the calling test.
|
|
36
|
+
* @param places Paths relative to that test file.
|
|
37
|
+
*/
|
|
38
|
+
export function guardRoots(base: string, places: readonly string[]): string[] {
|
|
39
|
+
const missing: string[] = []
|
|
40
|
+
const roots = places.map(place => {
|
|
41
|
+
const path = fileURLToPath(new URL(place, base))
|
|
42
|
+
if (!existsSync(path)) missing.push(place)
|
|
43
|
+
return path
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (missing.length) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`guard root missing: ${missing.join(' · ')}\n` +
|
|
49
|
+
'This guard reads a consumer\'s source, and that source is no longer at this path.\n' +
|
|
50
|
+
'The applications moved to the operato-application repository (ADR-0041 era). Move the\n' +
|
|
51
|
+
'consumer half of this guard there rather than deleting the root — a root removed here\n' +
|
|
52
|
+
'is a rule that stops being checked, and nothing will say so.'
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return roots
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve one file a guard reads, and fail loudly if it is gone.
|
|
61
|
+
*
|
|
62
|
+
* Same reason as above, for the guards that read a single named file rather than walking a
|
|
63
|
+
* tree. `readFileSync` would already throw here — this only replaces `ENOENT: no such file`
|
|
64
|
+
* with a sentence that says what to do.
|
|
65
|
+
*/
|
|
66
|
+
export function guardFile(base: string, place: string): string {
|
|
67
|
+
const path = fileURLToPath(new URL(place, base))
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`guard file missing: ${place}\n` +
|
|
71
|
+
'This guard reads a consumer\'s source. That file moved to the operato-application\n' +
|
|
72
|
+
'repository — move this assertion there rather than deleting it.'
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return path
|
|
76
|
+
}
|
|
@@ -14,11 +14,10 @@ import assert from 'node:assert/strict'
|
|
|
14
14
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
16
|
|
|
17
|
+
import { guardRoots } from './guard-roots.ts'
|
|
18
|
+
|
|
17
19
|
/** 훑는 곳 — 이 저장소에서 인제스트를 부를 수 있는 층 둘. */
|
|
18
|
-
const ROOTS = [
|
|
19
|
-
new URL('../server', import.meta.url).pathname,
|
|
20
|
-
new URL('../../operato-twin/server', import.meta.url).pathname
|
|
21
|
-
]
|
|
20
|
+
const ROOTS = guardRoots(import.meta.url, ['../server'])
|
|
22
21
|
|
|
23
22
|
/** 인제스트를 여는 함수 이름. 이 이름이 나오는 자리가 곧 유입 경로다. */
|
|
24
23
|
const INGEST_CALL = 'ingestCanonicalRecords('
|
|
@@ -24,11 +24,10 @@ import assert from 'node:assert/strict'
|
|
|
24
24
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
26
|
|
|
27
|
+
import { guardRoots } from './guard-roots.ts'
|
|
28
|
+
|
|
27
29
|
/** 훑는 곳 — 저널을 읽을 수 있는 층 둘. */
|
|
28
|
-
const ROOTS = [
|
|
29
|
-
new URL('../server', import.meta.url).pathname,
|
|
30
|
-
new URL('../../operato-twin/server', import.meta.url).pathname
|
|
31
|
-
]
|
|
30
|
+
const ROOTS = guardRoots(import.meta.url, ['../server'])
|
|
32
31
|
|
|
33
32
|
const READ = 'getRepository(TwinEvent)'
|
|
34
33
|
/** 폴드라고 밝히는 표식. 이유를 함께 적는 것이 이 표식의 값이다. */
|
|
@@ -25,6 +25,8 @@ import assert from 'node:assert/strict'
|
|
|
25
25
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
|
|
28
|
+
import { guardRoots } from './guard-roots.ts'
|
|
29
|
+
|
|
28
30
|
const ENGINE = new URL('../server/engine/twin-engine.ts', import.meta.url).pathname
|
|
29
31
|
const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutation.ts', import.meta.url).pathname
|
|
30
32
|
|
|
@@ -34,7 +36,7 @@ const MUTATION = new URL('../server/service/twin-lifecycle/twin-lifecycle-mutati
|
|
|
34
36
|
* `det.kind ?? 'wms'` 로 **엔진이 오류를 내기 전에 먼저 값을 정하고** 있었다. 커넥터 spec 도 마찬가지였다.
|
|
35
37
|
* 그래서 두 패키지의 서버 코드를 함께 훑는다.
|
|
36
38
|
*/
|
|
37
|
-
const ROOTS =
|
|
39
|
+
const ROOTS = guardRoots(import.meta.url, ['../server'])
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* 코드만 본다 — 주석은 **과거를 설명하느라 옛 코드를 그대로 인용한다**(바로 위 머리말이 그렇다).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Whether a fact was declared as having actually happened, promoted out of the payload.
|
|
3
|
+
*
|
|
4
|
+
* ── What this was for (measured 2026-09-09) ───────────────────────────────
|
|
5
|
+
* The twin's journal held 1,134 rows and every one of them was produced by a generator. There
|
|
6
|
+
* was no way to ask:
|
|
7
|
+
*
|
|
8
|
+
* select count(*) from twin_events 1134
|
|
9
|
+
* select count(*) where payload like '%SIM-%' 1134
|
|
10
|
+
* select count(*) where order_id like 'WO-SIM-%' 558 ← half of them
|
|
11
|
+
*
|
|
12
|
+
* The only signal was a naming convention on the sending side (`SIM-`, `WO-SIM-`), and the
|
|
13
|
+
* column that convention reaches was empty on half the rows. Meanwhile the screens compute
|
|
14
|
+
* yield and utilisation from those rows and cannot say what they are made of.
|
|
15
|
+
*
|
|
16
|
+
* ── The property that matters is the absent case ──────────────────────────
|
|
17
|
+
* `null` means the sender did not declare, which is **not** "it actually happened". Reading
|
|
18
|
+
* those two as the same thing is the whole defect: a generator that omits the field has its
|
|
19
|
+
* output counted as production, and nothing says so.
|
|
20
|
+
*
|
|
21
|
+
* So most of what is asserted here is about absence, not about the two declared values.
|
|
22
|
+
*/
|
|
23
|
+
import { test } from 'node:test'
|
|
24
|
+
import assert from 'node:assert/strict'
|
|
25
|
+
|
|
26
|
+
import { provenanceOf, twinEventKeys } from '../server/service/twin-event/twin-event-keys.js'
|
|
27
|
+
|
|
28
|
+
const envelope = (over: Record<string, unknown> = {}) => ({
|
|
29
|
+
eventId: 'e1',
|
|
30
|
+
eventType: 'task.status',
|
|
31
|
+
eventTime: '2026-09-09T02:00:00.000Z',
|
|
32
|
+
tenantId: 't1',
|
|
33
|
+
data: { taskId: 'SIM-ECU-0001-01', status: 'completed' },
|
|
34
|
+
...over
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('★ a fact declared simulated says so in a column, not only inside the payload', () => {
|
|
38
|
+
assert.equal(provenanceOf(envelope({ provenance: 'simulated' })), 'simulated')
|
|
39
|
+
assert.equal(twinEventKeys(envelope({ provenance: 'simulated' })).provenance, 'simulated')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('a fact declared actual says that', () => {
|
|
43
|
+
assert.equal(provenanceOf(envelope({ provenance: 'actual' })), 'actual')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('★ no declaration is not a declaration of actual — this is the defect being fixed', () => {
|
|
47
|
+
/*
|
|
48
|
+
* The payload here carries `SIM-…` in its task id, which is exactly how the 1,134 rows were
|
|
49
|
+
* recognisable by hand. The column still says nothing, because the sender said nothing. That
|
|
50
|
+
* is correct: the twin does not judge, it records what it was told.
|
|
51
|
+
*
|
|
52
|
+
* A reader has to distinguish three states. If it folds the last two together, a generator
|
|
53
|
+
* that forgets the field is counted as production.
|
|
54
|
+
*/
|
|
55
|
+
assert.equal(provenanceOf(envelope()), undefined)
|
|
56
|
+
assert.equal(twinEventKeys(envelope()).provenance, undefined)
|
|
57
|
+
|
|
58
|
+
assert.notEqual(provenanceOf(envelope()), 'actual', 'absent must never resolve to actual')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('a value the source made up does not reach the index', () => {
|
|
62
|
+
/*
|
|
63
|
+
* Same discipline as `actionOf`, which takes only ADD · OBSERVE · DELETE. A column with three
|
|
64
|
+
* possible values stops being answerable the moment a fourth arrives from a source we do not
|
|
65
|
+
* control.
|
|
66
|
+
*/
|
|
67
|
+
for (const bogus of ['ACTUAL', 'Simulated', 'fake', 'true', '', 'sim', 1, true, null, {}]) {
|
|
68
|
+
assert.equal(provenanceOf(envelope({ provenance: bogus })), undefined, `${JSON.stringify(bogus)} reached the column`)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('the declaration is read from the envelope, not from the source data', () => {
|
|
73
|
+
/*
|
|
74
|
+
* `data` is where the source system writes. If this were read from there, a source could
|
|
75
|
+
* declare its own facts actual by writing a key, and the declaration would no longer be the
|
|
76
|
+
* connecting side's statement. The envelope is ours.
|
|
77
|
+
*/
|
|
78
|
+
const sneaky = envelope({ data: { taskId: 'x', provenance: 'actual' } })
|
|
79
|
+
assert.equal(provenanceOf(sneaky), undefined, 'a value inside data was promoted')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('promoting it does not disturb the keys already promoted', () => {
|
|
83
|
+
/*
|
|
84
|
+
* This function is the write path's only promotion point, so adding one key is checked
|
|
85
|
+
* together with not disturbing the others.
|
|
86
|
+
*/
|
|
87
|
+
const full = twinEventKeys(
|
|
88
|
+
envelope({
|
|
89
|
+
provenance: 'simulated',
|
|
90
|
+
data: { action: 'OBSERVE', bizStep: 'commissioning', epcList: ['urn:epc:id:sgtin:1.2.3'], orderId: 'WO-1' }
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert.equal(full.action, 'OBSERVE')
|
|
95
|
+
assert.equal(full.bizStep, 'commissioning')
|
|
96
|
+
assert.equal(full.epc, 'urn:epc:id:sgtin:1.2.3')
|
|
97
|
+
assert.equal(full.orderId, 'WO-1')
|
|
98
|
+
assert.equal(full.provenance, 'simulated')
|
|
99
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A refusal has to name which envelope was refused.
|
|
3
|
+
*
|
|
4
|
+
* ── What this catches, and why the existing test did not (2026-09-08) ──────
|
|
5
|
+
* `rejectedForCaller` pairs a refused record with its envelope by **object identity** — value
|
|
6
|
+
* comparison would let two identically shaped records take each other's id. Its own test
|
|
7
|
+
* hands the same object to both sides, so it passes and proves the pairing works.
|
|
8
|
+
*
|
|
9
|
+
* It does not prove anything about the real path, because in between
|
|
10
|
+
* `ingestCanonicalRecords` builds `{ ...r, eventTime, sourceType }` — **a new object**. The
|
|
11
|
+
* refusal then carries that copy, the copy is not in the identity map, and the response comes
|
|
12
|
+
* back as `{ record, errors }` with no `eventId`:
|
|
13
|
+
*
|
|
14
|
+
* sent {"eventId":"probe-eventid-check-001","record":{"kind":"transformation"}}
|
|
15
|
+
* back rejected: [{ record: {kind:"transformation", sourceType:"twin"}, errors:[…] }]
|
|
16
|
+
*
|
|
17
|
+
* The sending side cannot mark the row that was refused, so it cannot settle the batch and
|
|
18
|
+
* its queue stops moving — measured on the plant side as 719 envelopes pending with nothing
|
|
19
|
+
* lost and nothing advancing.
|
|
20
|
+
*
|
|
21
|
+
* So this test walks the seam the way a real envelope does: through the ingest that makes the
|
|
22
|
+
* copy, then through the pairing. That is the step a pure test of either half cannot take.
|
|
23
|
+
*/
|
|
24
|
+
import { test } from 'node:test'
|
|
25
|
+
import assert from 'node:assert/strict'
|
|
26
|
+
|
|
27
|
+
import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.js'
|
|
28
|
+
import { rejectedForCaller } from '../server/service/reference/hook-rejected.js'
|
|
29
|
+
|
|
30
|
+
const AT = '2026-09-08T02:00:00.000Z'
|
|
31
|
+
|
|
32
|
+
/** A transformation the kernel refuses — no inputs, no outputs, no bizStep. */
|
|
33
|
+
const REFUSED = { kind: 'transformation' } as any
|
|
34
|
+
|
|
35
|
+
test('a refusal names the envelope it came from, after ingest has copied the record', () => {
|
|
36
|
+
/* The envelope as the hook holds it: the record object, and the id that identifies its row. */
|
|
37
|
+
const items = [{ record: REFUSED, eventId: 'evt-refused-1' }]
|
|
38
|
+
|
|
39
|
+
const result = ingestCanonicalRecords(
|
|
40
|
+
items.map(i => i.record),
|
|
41
|
+
'test-domain',
|
|
42
|
+
AT
|
|
43
|
+
) as any
|
|
44
|
+
|
|
45
|
+
assert.equal(result.rejected.length, 1, 'the kernel refuses this record')
|
|
46
|
+
|
|
47
|
+
const forCaller = rejectedForCaller(result.rejected, items)
|
|
48
|
+
|
|
49
|
+
assert.deepEqual(
|
|
50
|
+
forCaller.map((r: any) => r.eventId),
|
|
51
|
+
['evt-refused-1'],
|
|
52
|
+
'the refusal carries the envelope id — without it the sender cannot mark the row'
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('two refused records of the same shape keep their own ids', () => {
|
|
57
|
+
/*
|
|
58
|
+
* This is why the pairing is by identity and not by value. Both records look the same, and
|
|
59
|
+
* value comparison would hand the first id to both — the sender would mark one row twice
|
|
60
|
+
* and leave the other pending forever.
|
|
61
|
+
*/
|
|
62
|
+
const a = { kind: 'transformation' } as any
|
|
63
|
+
const b = { kind: 'transformation' } as any
|
|
64
|
+
const items = [
|
|
65
|
+
{ record: a, eventId: 'evt-a' },
|
|
66
|
+
{ record: b, eventId: 'evt-b' }
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const result = ingestCanonicalRecords(
|
|
70
|
+
items.map(i => i.record),
|
|
71
|
+
'test-domain',
|
|
72
|
+
AT
|
|
73
|
+
) as any
|
|
74
|
+
const forCaller = rejectedForCaller(result.rejected, items)
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
forCaller.map((r: any) => r.eventId).sort(),
|
|
78
|
+
['evt-a', 'evt-b'],
|
|
79
|
+
'each refusal keeps its own id'
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('an envelope with no id still says what was refused', () => {
|
|
84
|
+
/*
|
|
85
|
+
* A connector that does not number its envelopes gets the record back instead. An empty id
|
|
86
|
+
* would be worse than none: the sender would look for a row, find nothing, and treat the
|
|
87
|
+
* batch as understood.
|
|
88
|
+
*/
|
|
89
|
+
const items = [{ record: REFUSED }]
|
|
90
|
+
|
|
91
|
+
const result = ingestCanonicalRecords(
|
|
92
|
+
items.map(i => i.record),
|
|
93
|
+
'test-domain',
|
|
94
|
+
AT
|
|
95
|
+
) as any
|
|
96
|
+
const forCaller = rejectedForCaller(result.rejected, items) as any[]
|
|
97
|
+
|
|
98
|
+
assert.equal(forCaller.length, 1)
|
|
99
|
+
assert.equal('eventId' in forCaller[0], false, 'no id is sent rather than an empty one')
|
|
100
|
+
assert.ok('record' in forCaller[0], 'the record is sent so a person can see what was dropped')
|
|
101
|
+
})
|