@things-factory/headless-twin 10.0.17 → 10.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/canonical-ingest.d.ts +12 -85
- package/dist-server/engine/canonical-ingest.js +92 -26
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/energy-topology.js +3 -3
- package/dist-server/engine/energy-topology.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +91 -3
- package/dist-server/engine/kpi-fold.js +149 -25
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.js +99 -11
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/local-declarations.js +6 -5
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/model-vocabulary.js +2 -2
- package/dist-server/engine/model-vocabulary.js.map +1 -1
- package/dist-server/engine/oee-accumulator.d.ts +2 -1
- package/dist-server/engine/oee-accumulator.js +3 -2
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +72 -2
- package/dist-server/engine/twin-engine.js +150 -4
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/routes.js +13 -2
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
- package/dist-server/service/actuation/command-dispatcher.js +92 -0
- package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
- package/dist-server/service/actuation/command-store.d.ts +3 -0
- package/dist-server/service/actuation/command-store.js +56 -0
- package/dist-server/service/actuation/command-store.js.map +1 -0
- package/dist-server/service/actuation/index.d.ts +6 -0
- package/dist-server/service/actuation/index.js +11 -0
- package/dist-server/service/actuation/index.js.map +1 -0
- package/dist-server/service/actuation/twin-command.d.ts +30 -0
- package/dist-server/service/actuation/twin-command.js +110 -0
- package/dist-server/service/actuation/twin-command.js.map +1 -0
- package/dist-server/service/index.d.ts +2 -2
- package/dist-server/service/index.js +22 -16
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/hook-contract.d.ts +64 -21
- package/dist-server/service/reference/hook-contract.js +134 -22
- package/dist-server/service/reference/hook-contract.js.map +1 -1
- package/dist-server/service/reference/hook-store.d.ts +3 -0
- package/dist-server/service/reference/hook-store.js +28 -0
- package/dist-server/service/reference/hook-store.js.map +1 -0
- package/dist-server/service/reference/reference-adapter.d.ts +65 -1
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-hook.d.ts +42 -6
- package/dist-server/service/reference/reference-hook.js +185 -27
- package/dist-server/service/reference/reference-hook.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +1 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +11 -2
- package/dist-server/service/reference/reference-master.js +1 -1
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +2 -2
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
- package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
- package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
- package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
- package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
- package/dist-server/service/twin-backfill/index.d.ts +2 -0
- package/dist-server/service/twin-backfill/index.js +6 -0
- package/dist-server/service/twin-backfill/index.js.map +1 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
- package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
- package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/name-index.js +1 -1
- package/dist-server/service/twin-model/name-index.js.map +1 -1
- package/dist-server/service/twin-model/project-structure.js +4 -3
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
- package/dist-server/service/twin-model/twin-equipment.js +2 -2
- package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +4 -4
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.js +1 -1
- package/dist-shared/entity-delta.js.map +1 -1
- package/package.json +7 -6
- package/server/engine/canonical-ingest.ts +72 -84
- package/server/engine/energy-topology.ts +1 -1
- package/server/engine/kpi-fold.ts +219 -36
- package/server/engine/kpi-query.ts +97 -4
- package/server/engine/local-declarations.ts +2 -1
- package/server/engine/model-vocabulary.ts +1 -1
- package/server/engine/oee-accumulator.ts +4 -2
- package/server/engine/twin-engine.ts +163 -5
- package/server/routes.ts +18 -2
- package/server/service/actuation/command-dispatcher.ts +130 -0
- package/server/service/actuation/command-store.ts +62 -0
- package/server/service/actuation/index.ts +8 -0
- package/server/service/actuation/twin-command.ts +105 -0
- package/server/service/index.ts +6 -0
- package/server/service/reference/hook-contract.ts +99 -21
- package/server/service/reference/hook-store.ts +28 -0
- package/server/service/reference/reference-adapter.ts +67 -2
- package/server/service/reference/reference-hook.ts +249 -32
- package/server/service/reference/reference-live.ts +8 -2
- package/server/service/reference/reference-master.ts +12 -3
- package/server/service/reference/reference-resolver.ts +1 -1
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
- package/server/service/twin-backfill/backfill-shape.ts +86 -0
- package/server/service/twin-backfill/index.ts +3 -0
- package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
- package/server/service/twin-model/name-index.ts +1 -1
- package/server/service/twin-model/project-structure.ts +3 -2
- package/server/service/twin-model/twin-equipment.ts +14 -2
- package/server/service/twin-model/twin-model-item-query.ts +1 -1
- package/server/service/twin-model/twin-model-query.ts +2 -2
- package/server/service/twin-model/twin-model-tree-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +1 -1
- package/shared/entity-delta.ts +1 -1
- package/test/actuation-dispatch.test.ts +196 -0
- package/test/axis-read.test.ts +4 -3
- package/test/backfill-shape.test.ts +89 -0
- package/test/broadcast-cost-baseline.test.ts +2 -1
- package/test/canonical-ingest-vocabularies.test.ts +82 -3
- package/test/capability-mapping.test.ts +1 -1
- package/test/contract-layer-guard.test.ts +48 -0
- package/test/declaration-reaches-model.test.ts +2 -0
- package/test/equipment-identity-reaches.test.ts +57 -0
- package/test/fact-scope-wiring.test.ts +96 -0
- package/test/generated-not-counted.test.ts +99 -0
- package/test/hook-sequence.test.ts +333 -0
- package/test/ingest-bench.test.ts +2 -1
- package/test/ingest-wiring-guard.test.ts +197 -0
- package/test/kpi-fold.test.ts +81 -0
- package/test/live-mirror-parity.test.ts +6 -3
- package/test/local-declarations.test.ts +3 -2
- package/test/master-to-twin.test.ts +2 -2
- package/test/oee-accumulator.test.ts +18 -3
- package/test/projection-reaches-screen.test.ts +5 -0
- package/test/projection-reads-declared.test.ts +109 -0
- package/test/property-effects.test.ts +7 -3
- package/test/reference-hook.test.ts +114 -23
- package/test/scale-twin-bench.test.ts +2 -1
- package/test/vocabulary-guard.test.ts +1 -1
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -42,11 +42,12 @@
|
|
|
42
42
|
* 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지
|
|
43
43
|
* 함께 알립니다.
|
|
44
44
|
*/
|
|
45
|
-
import {
|
|
45
|
+
import { WEBHOOK_STATUS, checkSequence } from '@operato/ops-contract'
|
|
46
46
|
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
import {
|
|
47
|
+
import { twinWarn } from '../../engine/log.js'
|
|
48
|
+
|
|
49
|
+
import { getAdapter, type InboundBatch } from './reference-adapter.js'
|
|
50
|
+
import { authorizeHook, producedInstance, pushCursorKey, seqOf, withSeq, type HookOutcome } from './hook-contract.js'
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* 훅 하나를 처리한다 — 찾고 · 확인하고 · 커넥터에게 옮기게 하고 · 유입으로 넘긴다.
|
|
@@ -60,16 +61,33 @@ export async function handleHook(args: {
|
|
|
60
61
|
instanceId: string
|
|
61
62
|
headers: Record<string, unknown>
|
|
62
63
|
body: unknown
|
|
63
|
-
/**
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* 받은 바이트 그대로 — 서명을 확인하려면 이것이 있어야 한다.
|
|
66
|
+
*
|
|
67
|
+
* 파싱한 객체를 다시 문자열로 만들면 키 순서와 공백이 달라져 서명이 맞지 않는다. 없으면 서명을 쓰는
|
|
68
|
+
* 요청은 거절된다(§`authorizeHook`).
|
|
69
|
+
*/
|
|
70
|
+
rawBody?: string
|
|
71
|
+
/** 서명 창을 재는 기준 시각. 부르는 쪽이 준다 — 여기서 읽으면 시험이 시계에 매인다. */
|
|
72
|
+
nowMs?: number
|
|
73
|
+
/** 레코드를 트윈에 넣는다 — 받은 수와 **떨어진 것들**을 돌려준다. */
|
|
74
|
+
ingest: (instanceId: string, records: unknown[]) => IngestResult
|
|
75
|
+
/**
|
|
76
|
+
* 저장소를 대신 쓰는 자리 — **시험이 재기동을 넣을 수 있게.**
|
|
77
|
+
*
|
|
78
|
+
* `ingest` 를 부르는 쪽이 넘겨 주는 것과 같은 이유다(위 §). 커서는 재기동을 넘어 살아야 하는데, 한
|
|
79
|
+
* 프로세스 안에서만 확인하면 「적히나」는 보이지만 「다시 세우면 읽히나」는 안 보인다. 그 둘이 갈린
|
|
80
|
+
* 결함을 이 저장소가 이미 겪었다.
|
|
81
|
+
*/
|
|
82
|
+
store: HookStore
|
|
65
83
|
}): Promise<HookOutcome> {
|
|
66
|
-
const { domainId, source, instanceId, headers, body, ingest } = args
|
|
84
|
+
const { domainId, source, instanceId, headers, body, rawBody, ingest } = args
|
|
85
|
+
const nowMs = args.nowMs ?? Date.now()
|
|
86
|
+
const { store } = args
|
|
67
87
|
|
|
68
|
-
const ref = await
|
|
69
|
-
.findOne({ where: { domain: { id: domainId }, source } })
|
|
70
|
-
.catch(() => null)
|
|
88
|
+
const ref = await store.load(domainId, source)
|
|
71
89
|
if (!ref) {
|
|
72
|
-
return { status:
|
|
90
|
+
return { status: WEBHOOK_STATUS.unknownTarget, body: { ok: false, error: `unknown connection "${source}"` } }
|
|
73
91
|
}
|
|
74
92
|
|
|
75
93
|
const cfg: any = (ref as any).connectionConfig ?? {}
|
|
@@ -77,21 +95,22 @@ export async function handleHook(args: {
|
|
|
77
95
|
if (!expected) {
|
|
78
96
|
/* 비밀값을 선언하지 않은 연결은 훅을 받지 않는다 — 기본이 거부다. */
|
|
79
97
|
return {
|
|
80
|
-
status:
|
|
98
|
+
status: WEBHOOK_STATUS.badSecret,
|
|
81
99
|
body: {
|
|
82
100
|
ok: false,
|
|
83
101
|
error: `connection "${source}" declares no hookSecret — set one in the connection settings before pushing to it`
|
|
84
102
|
}
|
|
85
103
|
}
|
|
86
104
|
}
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
const auth = authorizeHook({ headers, rawBody, secret: expected, nowMs })
|
|
106
|
+
if (auth.ok === false) {
|
|
107
|
+
return { status: WEBHOOK_STATUS.badSecret, body: { ok: false, error: auth.reason } }
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
const site = producedInstance(ref as any, instanceId)
|
|
92
111
|
if (!site) {
|
|
93
112
|
return {
|
|
94
|
-
status:
|
|
113
|
+
status: WEBHOOK_STATUS.unknownTarget,
|
|
95
114
|
body: { ok: false, error: `twin "${instanceId}" was not created by connection "${source}"` }
|
|
96
115
|
}
|
|
97
116
|
}
|
|
@@ -99,47 +118,245 @@ export async function handleHook(args: {
|
|
|
99
118
|
const adapter: any = getAdapter(String((ref as any).adapterType ?? ''))
|
|
100
119
|
if (typeof adapter?.handleInbound !== 'function') {
|
|
101
120
|
return {
|
|
102
|
-
status:
|
|
121
|
+
status: WEBHOOK_STATUS.unsupported,
|
|
103
122
|
body: { ok: false, error: `connector "${(ref as any).adapterType}" cannot take pushed events (no handleInbound)` }
|
|
104
123
|
}
|
|
105
124
|
}
|
|
106
125
|
|
|
107
|
-
let
|
|
126
|
+
let batch: InboundBatch
|
|
108
127
|
try {
|
|
109
|
-
|
|
128
|
+
batch = adapter.handleInbound(cfg, site, body, headers)
|
|
110
129
|
} catch (e: any) {
|
|
111
130
|
/* 옮기지 못한 것은 그쪽 모양이 바뀐 것이다 — 다시 보내도 같으므로 재시도를 부르지 않는다. */
|
|
112
|
-
return { status:
|
|
131
|
+
return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'cannot translate the payload' } }
|
|
113
132
|
}
|
|
114
|
-
|
|
115
|
-
|
|
133
|
+
const items = batch?.items
|
|
134
|
+
if (!Array.isArray(items)) {
|
|
135
|
+
return {
|
|
136
|
+
status: WEBHOOK_STATUS.badPayload,
|
|
137
|
+
body: { ok: false, error: 'the connector did not return a list of items' }
|
|
138
|
+
}
|
|
116
139
|
}
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
* **번호 확인** — 이 자리가 프레임워크인 이유는 밀어 주는 모든 연결에 같은 규율이라야 하기 때문이다.
|
|
143
|
+
* 커넥터마다 만들면 한 곳이 빠지고, 빠진 그 연결에서만 사실이 없어진다.
|
|
144
|
+
*
|
|
145
|
+
* 번호를 매기지 않는 원본(chef · ppms)은 `seq` 를 주지 않고, 그때는 확인하지 않는다. 확인하지 않았다는
|
|
146
|
+
* 것은 응답에 남긴다 — 「확인했고 이상 없다」와 구별되어야 한다.
|
|
147
|
+
*/
|
|
148
|
+
const scope = typeof batch.scope === 'string' && batch.scope.trim() ? batch.scope.trim() : undefined
|
|
149
|
+
const withSeqCount = items.filter(i => typeof i?.seq === 'number').length
|
|
150
|
+
if (withSeqCount && withSeqCount !== items.length) {
|
|
151
|
+
/* 섞이면 빠진 것이 있는지 판단할 근거가 없다. 반쯤 확인한 것을 확인했다고 말하지 않는다. */
|
|
152
|
+
return {
|
|
153
|
+
status: WEBHOOK_STATUS.badPayload,
|
|
154
|
+
body: { ok: false, error: `mixed batch — ${withSeqCount} of ${items.length} items carry a sequence number` }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const numbered = withSeqCount > 0 && scope !== undefined
|
|
158
|
+
|
|
159
|
+
/*
|
|
160
|
+
* **첫 구멍에서 멈춘다.** 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다 — 보내는 쪽은
|
|
161
|
+
* `expectedSeq` 부터 다시 보내고, 뒤엣것은 그때 함께 온다.
|
|
162
|
+
*/
|
|
163
|
+
let take = items.length
|
|
164
|
+
let advanced: { scope: string; seq: number } | undefined
|
|
165
|
+
let gap: { expectedSeq: number; lastSeq: number } | undefined
|
|
166
|
+
if (numbered) {
|
|
167
|
+
/* 열쇠에 트윈을 넣는다 — 연결 하나가 트윈 여럿을 만들고 커서는 연결마다 한 행이다. */
|
|
168
|
+
const key = pushCursorKey(instanceId, scope as string)
|
|
169
|
+
let cursor = seqOf((ref as any).liveCursor, key)
|
|
170
|
+
take = 0
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
let verdict
|
|
173
|
+
try {
|
|
174
|
+
verdict = checkSequence(cursor, item.seq as number)
|
|
175
|
+
} catch (e: any) {
|
|
176
|
+
return { status: WEBHOOK_STATUS.badPayload, body: { ok: false, error: e?.message ?? 'bad sequence number' } }
|
|
177
|
+
}
|
|
178
|
+
if (verdict.kind === 'gap') {
|
|
179
|
+
gap = { expectedSeq: verdict.expectedSeq, lastSeq: verdict.lastSeq }
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
cursor = verdict.lastSeq
|
|
183
|
+
take++
|
|
184
|
+
}
|
|
185
|
+
/* 앞의 것을 하나도 못 받았으면 커서를 올릴 것이 없다. */
|
|
186
|
+
if (take > 0 && typeof cursor === 'number') advanced = { scope: key, seq: cursor }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const records = items.slice(0, take).map(i => i.record)
|
|
190
|
+
|
|
117
191
|
if (!records.length) {
|
|
118
|
-
/*
|
|
119
|
-
|
|
192
|
+
/*
|
|
193
|
+
* 옮길 것이 없는 것은 오류가 아니다 — 그쪽이 우리와 무관한 것을 보낸 것일 수 있다. 다만 첫 봉투부터
|
|
194
|
+
* 구멍이면 그것은 받은 것이 없는 것이고, 그 앞부터 달라고 해야 한다.
|
|
195
|
+
*/
|
|
196
|
+
if (gap) return gapOutcome(scope, gap)
|
|
197
|
+
if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq)
|
|
198
|
+
return {
|
|
199
|
+
status: WEBHOOK_STATUS.ok,
|
|
200
|
+
body: { ok: true, offered: 0, applied: 0, duplicates: 0, rejected: [], ...seqBody(scope, advanced, numbered) }
|
|
201
|
+
}
|
|
120
202
|
}
|
|
121
203
|
|
|
122
|
-
let result:
|
|
204
|
+
let result: IngestResult
|
|
123
205
|
try {
|
|
124
206
|
result = ingest(instanceId, records)
|
|
125
207
|
} catch (e: any) {
|
|
126
|
-
return { status:
|
|
208
|
+
return { status: WEBHOOK_STATUS.failed, body: { ok: false, error: e?.message ?? 'ingest failed' } }
|
|
127
209
|
}
|
|
210
|
+
const rejected = result.rejected ?? []
|
|
128
211
|
|
|
129
|
-
if (result.applied === 0 && result.duplicates === 0 &&
|
|
212
|
+
if (result.applied === 0 && result.duplicates === 0 && rejected.length === 0) {
|
|
130
213
|
/*
|
|
131
214
|
* 넣었는데 한 건도 반영되지 않았고 중복도 거부도 아니면, 그 트윈이 실시간으로 돌지 않는 것이다.
|
|
132
|
-
* 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.
|
|
133
|
-
*
|
|
215
|
+
* 200 을 주면 그쪽은 전달된 줄 알고 그 사실을 버린다.
|
|
216
|
+
*
|
|
217
|
+
* **커서를 올리지 않는다.** 한 건도 안 들어갔는데 번호를 올리면 그 번호는 다시 오지 않고, 트윈이
|
|
218
|
+
* 뜬 뒤에 그 구간이 비어 있게 된다.
|
|
134
219
|
*/
|
|
135
220
|
return {
|
|
136
|
-
status:
|
|
137
|
-
body: {
|
|
221
|
+
status: WEBHOOK_STATUS.notLive,
|
|
222
|
+
body: {
|
|
223
|
+
ok: false,
|
|
224
|
+
error: `twin "${instanceId}" is not running live — nothing was applied`,
|
|
225
|
+
offered: records.length,
|
|
226
|
+
retryAfterMs: NOT_LIVE_RETRY_MS
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (advanced) await saveSeq(store, ref as any, advanced.scope, advanced.seq)
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
* **구멍이 있으면 그것이 답을 정한다.** 앞의 것은 이미 들어갔으므로 `lastSeq` 까지는 보냄으로 표시하게
|
|
235
|
+
* 하고, 떨어진 것이 있으면 그것도 함께 싣는다 — 두 소식이 한 응답에 들어가야 보내는 쪽이 둘 다 한다.
|
|
236
|
+
*/
|
|
237
|
+
if (gap) return gapOutcome(scope, gap, rejected, advanced)
|
|
238
|
+
|
|
239
|
+
if (rejected.length) {
|
|
240
|
+
/*
|
|
241
|
+
* **다시 보내라고 하지 않는다.** 떨어진 것은 모양이 틀린 것이라 같은 것을 다시 보내도 또 떨어지고,
|
|
242
|
+
* 함께 간 나머지는 이미 들어갔으므로 두 번 들어간다.
|
|
243
|
+
*/
|
|
244
|
+
return {
|
|
245
|
+
status: WEBHOOK_STATUS.partial,
|
|
246
|
+
body: {
|
|
247
|
+
ok: false,
|
|
248
|
+
error: `${rejected.length} of ${records.length} records did not pass validation`,
|
|
249
|
+
offered: records.length,
|
|
250
|
+
applied: result.applied,
|
|
251
|
+
duplicates: result.duplicates,
|
|
252
|
+
rejected,
|
|
253
|
+
...seqBody(scope, advanced, numbered)
|
|
254
|
+
}
|
|
138
255
|
}
|
|
139
256
|
}
|
|
140
257
|
|
|
141
258
|
return {
|
|
142
|
-
status:
|
|
143
|
-
body: {
|
|
259
|
+
status: WEBHOOK_STATUS.ok,
|
|
260
|
+
body: {
|
|
261
|
+
ok: true,
|
|
262
|
+
offered: records.length,
|
|
263
|
+
applied: result.applied,
|
|
264
|
+
duplicates: result.duplicates,
|
|
265
|
+
rejected: [],
|
|
266
|
+
...seqBody(scope, advanced, numbered)
|
|
267
|
+
}
|
|
144
268
|
}
|
|
145
269
|
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 유입이 돌려주는 것.
|
|
273
|
+
*
|
|
274
|
+
* 떨어진 것을 **수가 아니라 목록으로** 받는다. 수만 받으면 보내는 쪽에 「몇 건이 떨어졌다」밖에 말할 수
|
|
275
|
+
* 없고, 그러면 무엇을 따로 두어야 할지 알 수 없어 배치 전체를 다시 보내거나 전부 버린다.
|
|
276
|
+
*/
|
|
277
|
+
export interface IngestResult {
|
|
278
|
+
applied: number
|
|
279
|
+
duplicates: number
|
|
280
|
+
rejected: { record: unknown; errors: string[] }[]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 구멍을 알리는 응답 — **앞의 것은 받았다는 것을 함께 말한다.**
|
|
285
|
+
*
|
|
286
|
+
* `lastSeq` 를 빼면 보내는 쪽은 이미 들어간 것까지 다시 보내고, 그것이 매번 중복으로 걸러지면서
|
|
287
|
+
* 처리량만 먹는다.
|
|
288
|
+
*/
|
|
289
|
+
function gapOutcome(
|
|
290
|
+
scope: string | undefined,
|
|
291
|
+
gap: { expectedSeq: number; lastSeq: number },
|
|
292
|
+
rejected: { record: unknown; errors: string[] }[] = [],
|
|
293
|
+
advanced?: { scope: string; seq: number }
|
|
294
|
+
): HookOutcome {
|
|
295
|
+
return {
|
|
296
|
+
status: WEBHOOK_STATUS.sequenceGap,
|
|
297
|
+
body: {
|
|
298
|
+
ok: false,
|
|
299
|
+
error: 'sequence gap',
|
|
300
|
+
scope,
|
|
301
|
+
expectedSeq: gap.expectedSeq,
|
|
302
|
+
lastSeq: advanced?.seq ?? gap.lastSeq,
|
|
303
|
+
...(rejected.length ? { rejected } : {})
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 트윈이 안 돌 때 얼마나 쉬라고 할 것인가.
|
|
310
|
+
*
|
|
311
|
+
* 사람이 트윈을 띄워야 풀리므로 초 단위로 다시 두드릴 이유가 없다. 그렇다고 길게 잡으면 트윈이 뜬 뒤
|
|
312
|
+
* 그만큼 늦게 이어진다.
|
|
313
|
+
*/
|
|
314
|
+
const NOT_LIVE_RETRY_MS = 30_000
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 응답에 번호를 어떻게 적나 — **확인한 것과 확인하지 않은 것을 구별한다.**
|
|
318
|
+
*
|
|
319
|
+
* 번호를 매기지 않는 원본은 확인할 것이 없다. 그것을 응답에서 「이상 없다」와 같아 보이게 두면, 어느
|
|
320
|
+
* 연결이 연속성을 지키고 있는지 아무도 알 수 없다.
|
|
321
|
+
*/
|
|
322
|
+
function seqBody(
|
|
323
|
+
scope: string | undefined,
|
|
324
|
+
advanced: { scope: string; seq: number } | undefined,
|
|
325
|
+
numbered: boolean
|
|
326
|
+
): Record<string, unknown> {
|
|
327
|
+
if (!numbered) return { sequenceChecked: false }
|
|
328
|
+
return { sequenceChecked: true, scope, lastSeq: advanced?.seq }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 커서에 번호를 적는다 — **반영한 뒤에만.**
|
|
333
|
+
*
|
|
334
|
+
* 넣기 전에 올리면, 넣다가 실패했을 때 그 번호는 다시 오지 않는다. 보내는 쪽은 200 을 못 받았으니 다시
|
|
335
|
+
* 보내겠지만, 그때는 커서가 이미 지나가 있어 `behind` 로 읽히고 버려진다.
|
|
336
|
+
*
|
|
337
|
+
* 적지 못하는 것은 사실 유입을 무르는 이유가 아니다 — 이미 들어간 사실은 그대로 두고, 다음 번호가 오면
|
|
338
|
+
* `gap` 으로 드러난다. 그래서 실패를 던지지 않고 남긴다.
|
|
339
|
+
*/
|
|
340
|
+
async function saveSeq(
|
|
341
|
+
store: HookStore,
|
|
342
|
+
ref: { id: string; liveCursor?: unknown },
|
|
343
|
+
scope: string,
|
|
344
|
+
seq: number
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
const next = withSeq(ref.liveCursor, scope, seq)
|
|
347
|
+
ref.liveCursor = next
|
|
348
|
+
await store
|
|
349
|
+
.saveCursor(ref.id, next)
|
|
350
|
+
.catch(err => twinWarn(`[twin-hook] 번호를 커서에 적지 못했다 (${scope}=${seq}) — ${err?.message ?? err}`))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 훅이 저장소에 닿는 두 자리 — 연결을 찾는 것과 커서를 적는 것.
|
|
355
|
+
*
|
|
356
|
+
* 이 둘만 밖으로 낸다. 나머지는 인자만 보고 답하므로 시험이 데이터베이스를 세우지 않아도 된다.
|
|
357
|
+
*/
|
|
358
|
+
export interface HookStore {
|
|
359
|
+
load(domainId: string, source: string): Promise<any | null>
|
|
360
|
+
saveCursor(refId: string, cursor: unknown): Promise<void>
|
|
361
|
+
}
|
|
362
|
+
|
|
@@ -2,7 +2,8 @@ import { twinLog, twinWarn, twinError } from '../../engine/log.js'
|
|
|
2
2
|
import { getRepository } from '@things-factory/shell'
|
|
3
3
|
|
|
4
4
|
import { getAdapter, type ConnectionConfig, type LiveFeedContinuity, type ObservedItemFact, type SiteDescriptor } from './reference-adapter.js'
|
|
5
|
-
import { TwinEngine, ingestCanonicalRecords, registerLiveFeedProbe
|
|
5
|
+
import { TwinEngine, ingestCanonicalRecords, registerLiveFeedProbe } from '../../engine/index.js'
|
|
6
|
+
import { type CanonicalRecord } from '@operato/ops-contract'
|
|
6
7
|
import { TwinInstance } from '../twin-instance/twin-instance.js'
|
|
7
8
|
import { TwinReference } from './twin-reference.js'
|
|
8
9
|
|
|
@@ -77,7 +78,12 @@ export async function startReferenceLiveFeed(
|
|
|
77
78
|
return
|
|
78
79
|
}
|
|
79
80
|
const offered = Array.isArray(records) ? records.length : records ? 1 : 0
|
|
80
|
-
const { accepted, rejected, masterData } = ingestCanonicalRecords(
|
|
81
|
+
const { accepted, rejected, masterData } = ingestCanonicalRecords(
|
|
82
|
+
records as CanonicalRecord[],
|
|
83
|
+
domainId,
|
|
84
|
+
new Date().toISOString(),
|
|
85
|
+
TwinEngine.factScope(domainId, instanceId)
|
|
86
|
+
)
|
|
81
87
|
/*
|
|
82
88
|
* ── **사건이 아닌 것은 상태만 세운다** (2026-08-28) ─────────────────────────
|
|
83
89
|
*
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 좌표·representations 는 트윈 공간층(보완/추가). 실 시스템이 좌표를 안 주면 layout 없이 위상만 인제스트.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/
|
|
10
|
+
import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/ops-contract'
|
|
11
11
|
|
|
12
12
|
export interface RefLocation {
|
|
13
13
|
id: string
|
|
@@ -150,7 +150,16 @@ export interface RefEquipment {
|
|
|
150
150
|
homeLocationId: string
|
|
151
151
|
mtbfMs?: number // 평균 고장 간격(확률적 고장) — 지정 시 커널이 breakdown 시뮬 → 주목(고장) 발생
|
|
152
152
|
mttrMs?: number // 평균 수리 시간
|
|
153
|
-
|
|
153
|
+
/**
|
|
154
|
+
* **이 설비의 전역 정체성** — 커널 선언의 `equipment[].identity` 로 그대로 간다.
|
|
155
|
+
*
|
|
156
|
+
* 예전 이름은 `gs1Id` 였다. 그런데 **GS1 키가 아닌 것이 정상이다** — 실측한 값은 인버터의
|
|
157
|
+
* 하드웨어 식별자(`ES100000007d8d061d-1`)이고, 원본이 GIAI 를 주면 그것도 여기 들어온다.
|
|
158
|
+
* 이름이 한 종류만 가리키면 다른 종류가 들어올 때 그 자리를 못 쓴다.
|
|
159
|
+
*
|
|
160
|
+
* 자리(`RefLocation.gs1Id`)는 그대로다 — 그쪽은 실제로 SGLN 이 온다.
|
|
161
|
+
*/
|
|
162
|
+
identity?: string
|
|
154
163
|
/**
|
|
155
164
|
* 교대(가동시간) — 이 시간대에만 배정된다. `startHour <= endHour` 면 같은 날, 넘어가면 야간(22→6).
|
|
156
165
|
* 미지정=24시간 가용. 교대 밖 자원은 고장·계획정지와 **구별되는 이유**로 쉰다(상태에 드러난다).
|
|
@@ -740,7 +749,7 @@ export function masterToTwin(master: ReferenceMaster, catalog?: any): IngestedTw
|
|
|
740
749
|
|
|
741
750
|
const equipment = (master.equipment ?? []).map(e => ({
|
|
742
751
|
id: e.id, kind: e.kind, ...(e.name ? { name: e.name } : {}), ...(e.description ? { description: e.description } : {}), ...(e.testResults?.length ? { testResults: e.testResults } : {}),
|
|
743
|
-
homeLocation: e.homeLocationId, mtbfMs: e.mtbfMs, mttrMs: e.mttrMs,
|
|
752
|
+
homeLocation: e.homeLocationId, mtbfMs: e.mtbfMs, mttrMs: e.mttrMs, ...(e.identity ? { identity: e.identity } : {}),
|
|
744
753
|
...(e.window ? { window: e.window } : {}),
|
|
745
754
|
/* 속성은 손대지 않고 통과 — 이동시간 추정기가 kind 별 속도를 여기서 읽는다(단위 코드 그대로). */
|
|
746
755
|
...(e.properties?.length ? { properties: e.properties } : {}), ...(e.workCalendar?.length ? { workCalendar: e.workCalendar } : {}), ...(e.testSpecificationIds?.length ? { testSpecificationIds: e.testSpecificationIds } : {}),
|
|
@@ -7,7 +7,7 @@ import { ScalarObject, getRepository } from '@things-factory/shell'
|
|
|
7
7
|
|
|
8
8
|
import { TwinEngine } from '../../engine/index.js'
|
|
9
9
|
import { readRestartPolicy } from '../../engine/restart-policy.js'
|
|
10
|
-
import { readBoardEquipment } from '@operato/
|
|
10
|
+
import { readBoardEquipment } from '@operato/ops-contract'
|
|
11
11
|
|
|
12
12
|
import { applyKnobDefaults, filledKnobKeys } from './knob-defaults.js'
|
|
13
13
|
import type { KnobDef } from './template-registry.js'
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 둘 다 같은 인제스트 경로(ingestMaster)로 흐른다 — 원천이 고정이냐 합성이냐만 다르다(board-authoring §10).
|
|
5
5
|
* 앱(operato-twin)이 bootstrap 에서 자기 아키타입을 registerTemplate 한다. 표현(map/image/model) 무관.
|
|
6
6
|
*/
|
|
7
|
-
import type { DomainSystem } from '@operato/
|
|
7
|
+
import type { DomainSystem } from '@operato/ops-contract'
|
|
8
8
|
|
|
9
9
|
import type { ReferenceMaster } from './reference-master.js'
|
|
10
10
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { getRepository } from '@things-factory/shell'
|
|
2
|
+
|
|
3
|
+
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
4
|
+
import { TwinEngine } from '../../engine/twin-engine.js'
|
|
5
|
+
import { ingestCanonicalRecords } from '../../engine/canonical-ingest.js'
|
|
6
|
+
import { absoluteTime } from '../../engine/read-time.js'
|
|
7
|
+
import { judgeBackfill, splitAlreadyThere } from './backfill-shape.js'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* 지난 기록 채우기 — **저널에만 쓴다. 트윈의 「지금」을 건드리지 않는다.**
|
|
11
|
+
*
|
|
12
|
+
* ── 왜 라이브 문으로 넣을 수 없나 (2026-08-30) ──────────────────────────────
|
|
13
|
+
* 커널의 상태는 「지금」이다. 늦게 온 옛 표본은 가드가 막는다(그렇게 만들었다). 지난 기록을 라이브
|
|
14
|
+
* 문으로 넣으면 둘 중 하나다 — 가드에 막히거나, 트윈의 시계가 과거로 끌린다. 둘 다 나쁘다.
|
|
15
|
+
*
|
|
16
|
+
* 그래서 이 길은 저널에만 쓴다. 상태는 그대로 두고, 저널을 읽는 쪽(성과 계산·이력 화면)만 그 사실을
|
|
17
|
+
* 보게 된다.
|
|
18
|
+
*
|
|
19
|
+
* ── 무엇을 받나 ────────────────────────────────────────────────────────────
|
|
20
|
+
* **마감된 구간 사실만** 받는다. 시점의 관측(계량 표본·설비 상태·발전 적산)은 받지 않는다 — 그것들은
|
|
21
|
+
* 커널의 상태를 바꾸는 사실이라, 상태를 건너뛰고 저널에만 넣으면 상태와 저널이 갈라진다.
|
|
22
|
+
*
|
|
23
|
+
* ── 같은 기간을 두 번 넣지 않는다 ──────────────────────────────────────────
|
|
24
|
+
* 마감된 구간 사실은 정체성이 내용에 있다(커널 0.7.81: 종류·대상·시작·끝). 그 id 로 이미 있는 것을
|
|
25
|
+
* 가려낸다. 저장소의 제약으로 막지 않는 이유는 드라이버가 다섯이고 충돌 처리가 제각각이기 때문이다 —
|
|
26
|
+
* 구간 단위로 한 번 읽어 가려내는 편이 이식성이 있고 값도 싸다(한 시간에 한 줄이면 하루 24줄이다).
|
|
27
|
+
*
|
|
28
|
+
* ── 보존보다 오래된 것은 받지 않는다 ───────────────────────────────────────
|
|
29
|
+
* 저널은 보존 기간이 지나면 지운다. 그보다 오래된 것을 채우면 다음 정리에서 지워진다. 조용히 넣고
|
|
30
|
+
* 지워지면 아무도 모르므로, **거절하고 이유를 말한다.**
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface BackfillResult {
|
|
34
|
+
/** 저널에 새로 적은 수. */
|
|
35
|
+
written: number
|
|
36
|
+
/** 이미 있어서 건너뛴 수 — 다시 읽어도 중복이 쌓이지 않는다는 뜻이다. */
|
|
37
|
+
skipped: number
|
|
38
|
+
/** 받지 않은 것과 그 이유. 조용히 버리지 않는다. */
|
|
39
|
+
refused: { record: unknown; reason: string }[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 이 도메인의 보존 기간(일). 정책이 없으면 `undefined` — 그때는 오래됨을 판정하지 않는다. */
|
|
43
|
+
async function retentionDaysOf(domainId: string): Promise<number | undefined> {
|
|
44
|
+
const perDomain = TwinEngine.retentionDaysOf ? await TwinEngine.retentionDaysOf(domainId).catch(() => undefined) : undefined
|
|
45
|
+
const days = perDomain ?? TwinEngine.JOURNAL_RETENTION_DAYS
|
|
46
|
+
return days && days > 0 ? days : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 지난 기록을 저널에 채운다.
|
|
51
|
+
*
|
|
52
|
+
* `records` 는 라이브와 **같은 모양**이다 — 커넥터가 다른 어휘를 배울 필요가 없다. 검증·갈림·id 만들기는
|
|
53
|
+
* 커널의 유입이 그대로 한다(§`ingestCanonicalRecords`).
|
|
54
|
+
*/
|
|
55
|
+
export async function backfillPeriodFacts(input: {
|
|
56
|
+
domainId: string
|
|
57
|
+
instanceId: string
|
|
58
|
+
records: unknown[]
|
|
59
|
+
/** 이 시각을 「지금」으로 본다 — 보존 판정의 기준. 시험이 시각을 정할 수 있게 받는다. */
|
|
60
|
+
nowMs?: number
|
|
61
|
+
}): Promise<BackfillResult> {
|
|
62
|
+
const { domainId, instanceId, records } = input
|
|
63
|
+
const nowMs = input.nowMs ?? Date.now()
|
|
64
|
+
const refused: { record: unknown; reason: string }[] = []
|
|
65
|
+
|
|
66
|
+
if (!Array.isArray(records) || !records.length) return { written: 0, skipped: 0, refused }
|
|
67
|
+
|
|
68
|
+
/* 검증과 갈림은 라이브와 같은 것을 쓴다 — 두 벌이 되면 채운 사실과 관측한 사실이 다른 규칙을 지난다. */
|
|
69
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
70
|
+
records as any,
|
|
71
|
+
domainId,
|
|
72
|
+
new Date(nowMs).toISOString(),
|
|
73
|
+
/* 어느 트윈의 사실인가 — 마감된 구간의 이름이 이것으로 갈린다(커널 §`SubjectBasis`). */
|
|
74
|
+
TwinEngine.factScope(domainId, instanceId)
|
|
75
|
+
)
|
|
76
|
+
for (const r of rejected) refused.push({ record: r.record, reason: (r.errors ?? []).join(' · ') })
|
|
77
|
+
|
|
78
|
+
const days = await retentionDaysOf(domainId)
|
|
79
|
+
|
|
80
|
+
const judged = judgeBackfill(accepted as any[], { nowMs, retentionDays: days })
|
|
81
|
+
refused.push(...judged.refused)
|
|
82
|
+
const keep = judged.keep
|
|
83
|
+
|
|
84
|
+
if (!keep.length) {
|
|
85
|
+
TwinEngine.recordIngestResult(
|
|
86
|
+
domainId,
|
|
87
|
+
instanceId,
|
|
88
|
+
records.length,
|
|
89
|
+
refused.map(r => ({ record: r.record, errors: [r.reason] })),
|
|
90
|
+
nowMs,
|
|
91
|
+
0
|
|
92
|
+
)
|
|
93
|
+
return { written: 0, skipped: 0, refused }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* 이미 있는 것을 가려낸다 — 그 구간의 그 종류만 읽는다(색인이 받쳐 준다). 페이로드의 id 로 견주고,
|
|
98
|
+
* 저장소 제약에 기대지 않는다(드라이버 다섯).
|
|
99
|
+
*/
|
|
100
|
+
const times = keep.map(e => absoluteTime(e.eventTime)!.getTime())
|
|
101
|
+
const fromAt = new Date(Math.min(...times))
|
|
102
|
+
const toAt = new Date(Math.max(...times))
|
|
103
|
+
const types = [...new Set(keep.map(e => String(e.eventType)))]
|
|
104
|
+
|
|
105
|
+
const existing = await getRepository(TwinEvent)
|
|
106
|
+
.createQueryBuilder('e')
|
|
107
|
+
.select(['e.payload AS "payload"'])
|
|
108
|
+
.where('e.domain = :domainId', { domainId })
|
|
109
|
+
.andWhere('e.instanceId = :instanceId', { instanceId })
|
|
110
|
+
.andWhere('e.eventType IN (:...types)', { types })
|
|
111
|
+
.andWhere('e.eventTime BETWEEN :fromAt AND :toAt', { fromAt, toAt })
|
|
112
|
+
.getRawMany<{ payload: unknown }>()
|
|
113
|
+
|
|
114
|
+
const seen = new Set<string>()
|
|
115
|
+
for (const row of existing) {
|
|
116
|
+
const p: any = typeof row.payload === 'string' ? safeParse(row.payload) : row.payload
|
|
117
|
+
const id = p?.eventId
|
|
118
|
+
if (typeof id === 'string' && id) seen.add(id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { fresh, skipped } = splitAlreadyThere(keep, seen)
|
|
122
|
+
if (!fresh.length) {
|
|
123
|
+
TwinEngine.recordIngestResult(
|
|
124
|
+
domainId,
|
|
125
|
+
instanceId,
|
|
126
|
+
records.length,
|
|
127
|
+
refused.map(r => ({ record: r.record, errors: [r.reason] })),
|
|
128
|
+
nowMs,
|
|
129
|
+
skipped
|
|
130
|
+
)
|
|
131
|
+
return { written: 0, skipped, refused }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
* 리비전은 **쓴 순서**다(일어난 순서가 아니다). 지난 사실이 지금 번호를 받는 것이 맞다 — 저널을
|
|
136
|
+
* 시각으로 읽는 쪽은 `eventTime` 을 보고, 리비전은 같은 시각을 가릴 때만 쓴다.
|
|
137
|
+
*/
|
|
138
|
+
const start = TwinEngine.journalHead(domainId, instanceId)
|
|
139
|
+
const items = fresh.map((event, i) => ({ event, revision: start + i + 1 }))
|
|
140
|
+
|
|
141
|
+
await TwinEngine.persistCarried(domainId, instanceId, items)
|
|
142
|
+
TwinEngine.advanceJournalHead(domainId, instanceId, items.length)
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* 장부에 적는다 — **유입을 부르는 곳은 모두 적는다.** 한 경로만 빠지면 그 트윈의 유입 건강이
|
|
146
|
+
* 「모름」으로 보인다(§`ingest-health-wiring`).
|
|
147
|
+
*
|
|
148
|
+
* 건네받지 못한 수(`undelivered`)는 **거절한 것과 이미 있어서 건너뛴 것의 합**이다. 건너뛴 것은
|
|
149
|
+
* 버려진 것이 아니지만 이번에 새로 적히지도 않았다 — 「통과」로 세면 채운 양이 실제보다 커 보인다.
|
|
150
|
+
*/
|
|
151
|
+
TwinEngine.recordIngestResult(
|
|
152
|
+
domainId,
|
|
153
|
+
instanceId,
|
|
154
|
+
records.length,
|
|
155
|
+
refused.map(r => ({ record: r.record, errors: [r.reason] })),
|
|
156
|
+
nowMs,
|
|
157
|
+
skipped
|
|
158
|
+
)
|
|
159
|
+
return { written: items.length, skipped, refused }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function safeParse(text: string): unknown {
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(text)
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined
|
|
167
|
+
}
|
|
168
|
+
}
|