@things-factory/headless-twin 10.1.4 → 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/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/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 +5 -4
- 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/twin-event/twin-event-keys.ts +31 -2
- package/server/service/twin-event/twin-event.ts +38 -0
- package/test/provenance-promotion.test.ts +99 -0
- 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
|
|
|
@@ -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
|
|
@@ -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
|
+
})
|