@things-factory/headless-twin 10.1.37 → 10.1.38
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/twin-engine.js +3 -2
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/twin-state-channel.d.ts +14 -0
- package/dist-server/engine/twin-state-channel.js +31 -0
- package/dist-server/engine/twin-state-channel.js.map +1 -0
- package/dist-server/service/reference/reference-progress-channel.d.ts +14 -0
- package/dist-server/service/reference/reference-progress-channel.js +24 -0
- package/dist-server/service/reference/reference-progress-channel.js.map +1 -0
- package/dist-server/service/reference/reference-progress-subscription.d.ts +2 -3
- package/dist-server/service/reference/reference-progress-subscription.js +14 -6
- package/dist-server/service/reference/reference-progress-subscription.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +3 -6
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-forecast/gap-analytics.d.ts +10 -1
- package/dist-server/service/twin-forecast/gap-analytics.js +15 -6
- package/dist-server/service/twin-forecast/gap-analytics.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.d.ts +2 -3
- package/dist-server/service/twin-state/twin-state-subscription.js +20 -13
- package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
- package/package.json +6 -6
- package/server/engine/twin-engine.ts +6 -5
- package/server/engine/twin-state-channel.ts +39 -0
- package/server/service/reference/reference-progress-channel.ts +26 -0
- package/server/service/reference/reference-progress-subscription.ts +15 -10
- package/server/service/reference/reference-resolver.ts +6 -6
- package/server/service/twin-forecast/gap-analytics.ts +15 -6
- package/server/service/twin-forecast/twin-forecast-query.ts +1 -1
- package/server/service/twin-state/twin-state-subscription.ts +22 -19
- package/test/boot-resume.test.ts +4 -1
- package/test/gap-analytics.test.ts +25 -1
- package/test/open-doors.test.ts +197 -0
- package/test/twin-state-channel.test.ts +111 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pubsub } from '@things-factory/shell'
|
|
2
2
|
import { HOST_STEPS, type ReferenceProgress } from './reference-progress.js'
|
|
3
|
+
import { REFERENCE_PROGRESS_TOPIC, referenceProgressPayload } from './reference-progress-channel.js'
|
|
3
4
|
import { assessMaster } from './reference-assessment.js'
|
|
4
5
|
import { Arg, Ctx, Int, Mutation, Query, Resolver, Directive } from 'type-graphql'
|
|
5
6
|
|
|
@@ -295,9 +296,10 @@ export class TwinReferenceResolver {
|
|
|
295
296
|
if (!progressSource) return
|
|
296
297
|
lastDone = p.done
|
|
297
298
|
lastStep = p.step
|
|
298
|
-
pubsub.publish(
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
pubsub.publish(
|
|
300
|
+
REFERENCE_PROGRESS_TOPIC,
|
|
301
|
+
referenceProgressPayload(domainId, { ...p, siteId, siteIndex: 0, siteCount: 1, total: p.total ?? stepTotal, source: progressSource })
|
|
302
|
+
)
|
|
301
303
|
}
|
|
302
304
|
|
|
303
305
|
try {
|
|
@@ -835,9 +837,7 @@ export class TwinReferenceResolver {
|
|
|
835
837
|
const declared = adapter.masterSteps?.length
|
|
836
838
|
const stepTotal = declared === undefined ? undefined : declared + HOST_STEPS.length
|
|
837
839
|
const emit = (p: Omit<ReferenceProgress, 'siteCount'>) =>
|
|
838
|
-
pubsub.publish(
|
|
839
|
-
twinReferenceProgress: { ...p, siteCount: sites.length, total: p.total ?? stepTotal, source }
|
|
840
|
-
})
|
|
840
|
+
pubsub.publish(REFERENCE_PROGRESS_TOPIC, referenceProgressPayload(domainId, { ...p, siteCount: sites.length, total: p.total ?? stepTotal, source }))
|
|
841
841
|
|
|
842
842
|
/*
|
|
843
843
|
* **읽은 뒤의 총평을 사이트마다 남긴다** (2026-08-22).
|
|
@@ -99,19 +99,27 @@ export function computeSpc(errors: number[]): any {
|
|
|
99
99
|
return { cl, ucl, lcl, sigma, n, ooc, instability, stable: !instability, biased, biasSign: cl >= 0 ? 'under' : 'over', trackingSignal }
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* 집계 지표 — 정확도(1−WMAPE)·편향(MPE)·예측구간 적중·FVA(예측 가치).
|
|
104
|
+
*
|
|
105
|
+
* A percentage whose denominator is zero is **not measured**, and says so with `null` (2026-09-24).
|
|
106
|
+
* When every actual in the window is 0 — the line stood still, or the metric was not observed — WMAPE
|
|
107
|
+
* is undefined. It used to become 0, so the card read "100% accurate" beside "0 of 6 in band, mean
|
|
108
|
+
* error −20". Likewise `fvaPct` when the naive error is 0: 0 read as "the forecast adds nothing".
|
|
109
|
+
* The absolute measures (mean error, in-band count, fva) are real in that window and stay. The two
|
|
110
|
+
* neighbours below already refuse on thin evidence (`computeSpc` · `learnedCalibration`).
|
|
111
|
+
*/
|
|
103
112
|
export function gapMetrics(pts: GapPoint[]): any {
|
|
104
113
|
const sumAbsErr = pts.reduce((s, x) => s + Math.abs(x.error), 0)
|
|
105
114
|
const sumActual = pts.reduce((s, x) => s + Math.abs(x.actual), 0)
|
|
106
|
-
const
|
|
107
|
-
const accuracy = Math.max(0, Math.round((1 - wmape) * 100))
|
|
115
|
+
const accuracy = sumActual > 0 ? Math.max(0, Math.round((1 - sumAbsErr / sumActual) * 100)) : null
|
|
108
116
|
const mpe = pts.length ? pts.reduce((s, x) => s + x.error, 0) / pts.length : 0
|
|
109
117
|
const coverage = pts.length ? pts.filter(x => x.inBand).length / pts.length : 0
|
|
110
118
|
// 예측 가치(FVA) — "현재값 그대로"(naive) 대비 오차 감소. fva=감소 총량, fvaPct=naive 오차를 몇 % 줄였나(가치의 크기).
|
|
111
119
|
const sumNaive = pts.reduce((s, x) => s + x.naiveAbsError, 0)
|
|
112
120
|
const sumModel = pts.reduce((s, x) => s + x.modelAbsError, 0)
|
|
113
121
|
const fva = sumNaive - sumModel
|
|
114
|
-
const fvaPct = sumNaive > 0 ? Math.round((1 - sumModel / sumNaive) * 100) :
|
|
122
|
+
const fvaPct = sumNaive > 0 ? Math.round((1 - sumModel / sumNaive) * 100) : null
|
|
115
123
|
return { accuracy, mpe, coverage, inBandCount: pts.filter(x => x.inBand).length, total: pts.length, fva, fvaPct }
|
|
116
124
|
}
|
|
117
125
|
|
|
@@ -123,8 +131,9 @@ export function learnedCalibration(pts: GapPoint[], metrics: any): any {
|
|
|
123
131
|
const corrected = errs.map(e => e - biasShift) // 편향 제거 후 잔차
|
|
124
132
|
const absC = corrected.map(Math.abs).sort((a, b) => a - b)
|
|
125
133
|
const halfWidth = absC[Math.floor(absC.length * 0.8)] ?? (absC[absC.length - 1] || 1) // 80% 커버리지 밴드
|
|
126
|
-
|
|
127
|
-
const
|
|
134
|
+
/* The bias is learnable with every actual at 0; the accuracy before and after is not (see gapMetrics). */
|
|
135
|
+
const sumActual = pts.reduce((s, p) => s + Math.abs(p.actual), 0)
|
|
136
|
+
const accAfter = sumActual > 0 ? Math.max(0, Math.round((1 - corrected.reduce((s, e) => s + Math.abs(e), 0) / sumActual) * 100)) : null
|
|
128
137
|
const covAfter = corrected.filter(e => Math.abs(e) <= halfWidth).length
|
|
129
138
|
return { biasShift: Math.round(biasShift), halfWidth: Math.round(halfWidth), accBefore: metrics.accuracy, accAfter, covBefore: metrics.inBandCount, covAfter, n: pts.length }
|
|
130
139
|
}
|
|
@@ -72,7 +72,7 @@ function metricFn(metric?: string): MetricFn | null {
|
|
|
72
72
|
데모 트윈은 재부팅 시 저널 리셋이라 세션 스코프로 충분 — DB 영속(TwinForecastScore)은 후속. */
|
|
73
73
|
const CALIBRATION = new Map<
|
|
74
74
|
string,
|
|
75
|
-
{ biasShift: number; halfWidth: number; at: string; samples: number; accBefore: number; accAfter: number; basis?: ModelBasis }
|
|
75
|
+
{ biasShift: number; halfWidth: number; at: string; samples: number; accBefore: number | null; accAfter: number | null; basis?: ModelBasis }
|
|
76
76
|
>()
|
|
77
77
|
const calKey = (d: string, i: string, m: string, h: number) => `${d}:${i}:${m}:${h}`
|
|
78
78
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { filter, pipe } from 'graphql-yoga'
|
|
2
2
|
import { Arg, Field, Int, ObjectType, Resolver, Root, Subscription } from 'type-graphql'
|
|
3
3
|
|
|
4
|
-
import { pubsub, ScalarObject } from '@things-factory/shell'
|
|
4
|
+
import { assertDomainSubscribeAllowed, pubsub, ScalarObject } from '@things-factory/shell'
|
|
5
5
|
|
|
6
6
|
import { TwinEngine } from '../../engine/index.js'
|
|
7
|
+
import { TWIN_STATE_TOPIC, twinStateFilter, twinStatePayload, type TwinStatePayload } from '../../engine/twin-state-channel.js'
|
|
7
8
|
|
|
8
9
|
/* State 채널 메시지 — snapshot | delta | clock 멀티플렉싱(커널 SubscriptionMessage 대응). */
|
|
9
10
|
@ObjectType({ description: 'Twin State channel message multiplexing snapshot | delta | clock.' })
|
|
@@ -23,37 +24,39 @@ export class TwinStateMessage {
|
|
|
23
24
|
|
|
24
25
|
/*
|
|
25
26
|
* 트윈 State 구독 — state-register/data-resolver 패턴 미러.
|
|
26
|
-
* 구독 즉시 현재 snapshot 발행(process.nextTick) 후 delta/clock 스트림.
|
|
27
|
+
* 구독 즉시 현재 snapshot 발행(process.nextTick) 후 delta/clock 스트림.
|
|
28
|
+
*
|
|
29
|
+
* The tenant is asked while streaming, not only at subscribe time (2026-09-24). Ownership used to be
|
|
30
|
+
* checked once and the stream filtered on instanceId alone; the same id in two tenants is the normal
|
|
31
|
+
* case (`runtime-key.ts`), so other tenants' deltas came through, and the snapshot sent to a new
|
|
32
|
+
* subscriber reached every tenant subscribed to that id. Messages now carry their domain and the filter
|
|
33
|
+
* compares it (`engine/twin-state-channel.ts`). A twin the caller does not own is refused rather than
|
|
34
|
+
* given an empty stream — an empty stream cannot tell "nothing changed" from "you may not see this"
|
|
35
|
+
* (shell's `data` throws too).
|
|
27
36
|
*/
|
|
28
37
|
@Resolver()
|
|
29
38
|
export class TwinStateSubscription {
|
|
30
39
|
@Subscription(returns => TwinStateMessage, {
|
|
31
|
-
subscribe: ({ args, context }) => {
|
|
40
|
+
subscribe: async ({ args, context }) => {
|
|
32
41
|
const { instanceId } = args
|
|
33
|
-
const
|
|
42
|
+
const { domain, user } = (context as any)?.state ?? {}
|
|
43
|
+
await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
|
|
34
44
|
|
|
35
|
-
/*
|
|
36
|
-
if (!
|
|
37
|
-
return pipe(pubsub.subscribe('twin-state'), filter(() => false))
|
|
38
|
-
}
|
|
45
|
+
/* One refusal for both cases, so the answer does not reveal whether another tenant has such a twin. */
|
|
46
|
+
if (!TwinEngine.owns(domain.id, instanceId)) throw new Error(`twin instance not running in this tenant: ${instanceId}`)
|
|
39
47
|
|
|
40
|
-
/*
|
|
48
|
+
/* Current state first (a late subscriber still sees snapshot → delta). It reaches this tenant's subscribers only. */
|
|
41
49
|
process.nextTick(() => {
|
|
42
|
-
const snap = TwinEngine.snapshot(
|
|
50
|
+
const snap = TwinEngine.snapshot(domain.id, instanceId)
|
|
43
51
|
if (snap) {
|
|
44
|
-
pubsub.publish('
|
|
45
|
-
twinState: { instanceId, kind: 'snapshot', revision: snap.revision, payload: snap }
|
|
46
|
-
})
|
|
52
|
+
pubsub.publish(TWIN_STATE_TOPIC, twinStatePayload(domain.id, { instanceId, kind: 'snapshot', revision: snap.revision, payload: snap }))
|
|
47
53
|
}
|
|
48
54
|
})
|
|
49
55
|
|
|
50
|
-
return pipe(
|
|
51
|
-
pubsub.subscribe('twin-state'),
|
|
52
|
-
filter((payload: { twinState: TwinStateMessage }) => payload.twinState.instanceId === instanceId)
|
|
53
|
-
)
|
|
56
|
+
return pipe(pubsub.subscribe(TWIN_STATE_TOPIC), filter(twinStateFilter(domain.id, instanceId)))
|
|
54
57
|
}
|
|
55
58
|
})
|
|
56
|
-
twinState(@Root() payload:
|
|
57
|
-
return payload.twinState
|
|
59
|
+
twinState(@Root() payload: TwinStatePayload, @Arg('instanceId') instanceId: string): TwinStateMessage {
|
|
60
|
+
return payload.twinState as TwinStateMessage
|
|
58
61
|
}
|
|
59
62
|
}
|
package/test/boot-resume.test.ts
CHANGED
|
@@ -177,7 +177,10 @@ test('flush 가 시뮬도 본다 — 브로드캐스팅과 저널은 둘 다, st
|
|
|
177
177
|
assert.ok(liveOnlyAt >= 0, 'state 채널을 라이브로 가르는 자리가 없다')
|
|
178
178
|
assert.ok(flushAt < liveOnlyAt, '저널 flush 가 라이브 전용 구간 뒤로 밀리면 시뮬의 사실이 쌓인 채 남는다')
|
|
179
179
|
/* state 채널은 시뮬이 자기 콜백에서 이미 보낸다 — 여기서 또 보내면 같은 신호가 두 번 간다. */
|
|
180
|
-
|
|
180
|
+
/* The payload is built by the channel since 2026-09-24 (it carries the domain); look for that call. */
|
|
181
|
+
const stateSendAt = body.indexOf('twinStatePayload(inst.domainId, { instanceId: inst.id')
|
|
182
|
+
assert.ok(stateSendAt >= 0, 'state 채널로 보내는 자리가 없다')
|
|
183
|
+
assert.ok(stateSendAt > liveOnlyAt, 'state 채널 브로드캐스팅은 라이브 구간에 있어야 한다')
|
|
181
184
|
})
|
|
182
185
|
|
|
183
186
|
test('구독자 하나가 호스트를 죽이지 못한다 — 그리고 못 보낸 것을 보낸 것으로 적지 않는다', () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { test } from 'node:test'
|
|
6
6
|
import assert from 'node:assert/strict'
|
|
7
7
|
|
|
8
|
-
import { computeSpc, gapMetrics, learnedCalibration, aggregatePoints } from '../
|
|
8
|
+
import { computeSpc, gapMetrics, learnedCalibration, aggregatePoints } from '../server/service/twin-forecast/gap-analytics.ts'
|
|
9
9
|
|
|
10
10
|
const pt = (error: number, actual = 100, inBand = true) => ({
|
|
11
11
|
t: 0, p50: actual - error, min: 0, max: 0, actual, error, inBand,
|
|
@@ -52,6 +52,30 @@ test('learnedCalibration — biasShift=평균오차, 편향 제거로 정확도
|
|
|
52
52
|
assert.equal(learn.accAfter, 100, '일정 편향을 완전 제거 → 잔차 0 → 100%')
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
+
/*
|
|
56
|
+
* ★ Nothing to divide by is not 100% (2026-09-24). With every actual at 0 the card read
|
|
57
|
+
* "100% accurate" beside "0 of 6 in band, mean error −20" — the installed build gave exactly that.
|
|
58
|
+
*/
|
|
59
|
+
test('gapMetrics — a window with no actual says the percentages are not measured, and keeps what is', () => {
|
|
60
|
+
const idle = Array.from({ length: 6 }, () => ({ t: 0, p50: 20, min: 15, max: 25, actual: 0, error: -20, inBand: false, naiveAbsError: 0, modelAbsError: 20 }))
|
|
61
|
+
const m = gapMetrics(idle)
|
|
62
|
+
assert.equal(m.accuracy, null, 'WMAPE has no denominator — not 100%')
|
|
63
|
+
assert.equal(m.fvaPct, null, 'the naive error is 0 — "adds nothing" would be invented')
|
|
64
|
+
/* Measured in that window, so they stay. */
|
|
65
|
+
assert.equal(m.mpe, -20)
|
|
66
|
+
assert.equal(m.inBandCount, 0)
|
|
67
|
+
assert.equal(m.total, 6)
|
|
68
|
+
assert.equal(m.fva, -120)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('learnedCalibration — the bias is still learned with no actual; only the percentages are not', () => {
|
|
72
|
+
const idle = Array.from({ length: 4 }, () => pt(-20, 0, false))
|
|
73
|
+
const learn = learnedCalibration(idle, gapMetrics(idle))
|
|
74
|
+
assert.equal(learn.biasShift, -20, 'forecasting 20 over a standing line is a bias worth removing')
|
|
75
|
+
assert.equal(learn.accBefore, null)
|
|
76
|
+
assert.equal(learn.accAfter, null, 'no denominator was made up (it used to be `|| 1`)')
|
|
77
|
+
})
|
|
78
|
+
|
|
55
79
|
/* 같은 격자 위의 점 — vantage 를 명시한다(예전 헬퍼는 t 를 늘 0 으로 두어 시간축을 전혀 시험하지 않았다). */
|
|
56
80
|
const at = (t: number, error: number, actual = 100) => ({ ...pt(error, actual), t })
|
|
57
81
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which of headless-twin's doors are open, and why each one is allowed to be.
|
|
3
|
+
*
|
|
4
|
+
* ── The thing this catches ──────────────────────────────────────────────────
|
|
5
|
+
* `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is
|
|
6
|
+
* indistinguishable from a field somebody decided to open. The list below turns the absence into a
|
|
7
|
+
* statement: anything open that is not on it fails, and the reason has to be written first.
|
|
8
|
+
* Same measure as `auth-base/tests/open-doors.test.ts`.
|
|
9
|
+
*
|
|
10
|
+
* ── The decision the reads rest on ──────────────────────────────────────────
|
|
11
|
+
* ADR-0027: the twin gates **writes** only. Reading is the product's purpose, and the boundary for a
|
|
12
|
+
* read is tenant membership — so an ungated read is correct only if its body narrows to the caller's
|
|
13
|
+
* domain. Every entry below says how it does. (The one read that crosses tenants on purpose,
|
|
14
|
+
* `twinLoad`, is super-user gated and so is not here.) A door that answers from the installation
|
|
15
|
+
* rather than from any tenant's rows says so instead.
|
|
16
|
+
*
|
|
17
|
+
* ── Why this reads the build ────────────────────────────────────────────────
|
|
18
|
+
* The schema is built from `service/index`'s own `resolverClasses`, the list the server boots from.
|
|
19
|
+
* type-graphql needs decorator metadata to build it, and Node's type stripping (how the other tests
|
|
20
|
+
* here run the source) does not emit it. So this one reads `dist-server`. A stale build would miss a
|
|
21
|
+
* door added since, and pass — hence the freshness check before anything else.
|
|
22
|
+
*/
|
|
23
|
+
import { test } from 'node:test'
|
|
24
|
+
import assert from 'node:assert/strict'
|
|
25
|
+
import { createRequire } from 'node:module'
|
|
26
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
27
|
+
import { dirname, join, relative } from 'node:path'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
|
|
30
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
31
|
+
const DIST = join(PKG, 'dist-server')
|
|
32
|
+
|
|
33
|
+
/* Resolver sources newer than their build: the schema below would not be the one served. */
|
|
34
|
+
function staleResolvers(): string[] {
|
|
35
|
+
const out: string[] = []
|
|
36
|
+
const walk = (dir: string) => {
|
|
37
|
+
for (const name of readdirSync(dir)) {
|
|
38
|
+
const p = join(dir, name)
|
|
39
|
+
if (statSync(p).isDirectory()) walk(p)
|
|
40
|
+
else if (p.endsWith('.ts') && /@(Query|Mutation|Subscription)\(/.test(readFileSync(p, 'utf8'))) {
|
|
41
|
+
const built = join(DIST, relative(join(PKG, 'server'), p)).replace(/\.ts$/, '.js')
|
|
42
|
+
if (!existsSync(built) || statSync(built).mtimeMs < statSync(p).mtimeMs) out.push(relative(PKG, p))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
walk(join(PKG, 'server'))
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const INSTALLATION = 'answers from the installation, not from any tenant\'s rows'
|
|
51
|
+
const DOMAIN_ROWS = 'reads rows where domain = the caller\'s'
|
|
52
|
+
const DOMAIN_RUNTIME = 'reads the runtime keyed (caller\'s domain, instanceId)'
|
|
53
|
+
|
|
54
|
+
/** Open on purpose (ADR-0027), each with how it stays inside the caller's tenant. */
|
|
55
|
+
const OPEN_BY_DECISION: Record<'Query' | 'Subscription', Record<string, string>> = {
|
|
56
|
+
Query: {
|
|
57
|
+
/* Catalogues — what this build can do. */
|
|
58
|
+
referenceAdapterTypes: `${INSTALLATION}: registered connector type keys`,
|
|
59
|
+
referenceAdapters: `${INSTALLATION}: connector metadata and judged capabilities`,
|
|
60
|
+
twinDomainCatalog: `${INSTALLATION}: the kernel's location-type vocabulary`,
|
|
61
|
+
twinTargetMetrics: `${INSTALLATION}: the metric ids a target may name`,
|
|
62
|
+
twinTemplates: `${INSTALLATION}: registered twin templates and their knobs`,
|
|
63
|
+
|
|
64
|
+
/* Connections and references. */
|
|
65
|
+
discoverReferenceSites: `${DOMAIN_ROWS} (TwinReference by source), then asks that connection`,
|
|
66
|
+
referenceSources: `${DOMAIN_ROWS} (TwinInstance) and the caller's runtimes`,
|
|
67
|
+
twinConnection: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`,
|
|
68
|
+
twinConnections: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`,
|
|
69
|
+
twinReference: `${DOMAIN_ROWS} (TwinReference)`,
|
|
70
|
+
twinReferences: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`,
|
|
71
|
+
twinReferenceReconciliation: `${DOMAIN_ROWS} (TwinReferenceReconciliation)`,
|
|
72
|
+
twinReferenceReconciliations: `${DOMAIN_ROWS} (TwinReferenceReconciliation)`,
|
|
73
|
+
|
|
74
|
+
/* Actuation — reading what was proposed and how it went. Acting is gated. */
|
|
75
|
+
twinActuationOpinion: 'loads the command by (caller\'s domain, id); refuses without a domain',
|
|
76
|
+
twinActuationOutcome: 'loads the command by (caller\'s domain, id); refuses without a domain',
|
|
77
|
+
twinActuationReadiness: 'loads the command by (caller\'s domain, id); refuses without a domain',
|
|
78
|
+
twinActuationRules: `${DOMAIN_ROWS} (TwinActuationRule list params with the caller's domain)`,
|
|
79
|
+
twinCommands: `${DOMAIN_ROWS} (TwinCommandRecord list params with the caller's domain)`,
|
|
80
|
+
|
|
81
|
+
/* Instances and lifecycle. */
|
|
82
|
+
twinAdoptionPath: `${DOMAIN_ROWS} after an ownership check`,
|
|
83
|
+
twinDeletionPreview: `${DOMAIN_ROWS} (TwinInstance, TwinEvent) — describes, deletes nothing`,
|
|
84
|
+
twinInstanceDetail: `${DOMAIN_ROWS} (TwinEngine.detail)`,
|
|
85
|
+
twinInstanceList: `${DOMAIN_ROWS} (TwinEngine.list)`,
|
|
86
|
+
twinInstances: `${DOMAIN_ROWS} (TwinEngine.list)`,
|
|
87
|
+
twinSourceCapability: `${DOMAIN_RUNTIME}; empty without a domain`,
|
|
88
|
+
twinSpaceInstanceIds: `${DOMAIN_ROWS} (TwinEngine.instanceIdsOfSpace)`,
|
|
89
|
+
|
|
90
|
+
/* Journal, history, time travel. */
|
|
91
|
+
twinAuditList: `${DOMAIN_ROWS} (TwinAuditEvent list params with the caller's domain)`,
|
|
92
|
+
twinCapacity: `${DOMAIN_RUNTIME} after an ownership check`,
|
|
93
|
+
twinEventList: `${DOMAIN_ROWS} (TwinEvent) for targets resolved inside the caller's domain`,
|
|
94
|
+
twinEvents: `${DOMAIN_ROWS} (TwinEvent)`,
|
|
95
|
+
twinKpi: `${DOMAIN_ROWS} after an ownership check`,
|
|
96
|
+
twinOperationsCapability: `${DOMAIN_RUNTIME} after an ownership check`,
|
|
97
|
+
twinReplay: `${DOMAIN_RUNTIME} or its journal (TwinEngine.recover)`,
|
|
98
|
+
twinStructures: `${DOMAIN_ROWS} (TwinStructure, TwinEvent) after an ownership check`,
|
|
99
|
+
twinSubjectHistory: `${DOMAIN_ROWS} (TwinSubjectEvent)`,
|
|
100
|
+
twinTimeRange: `${DOMAIN_ROWS} (TwinInstance, TwinEvent)`,
|
|
101
|
+
|
|
102
|
+
/* Model and entity views. */
|
|
103
|
+
spaceDeclarations: `${DOMAIN_ROWS} (TwinSpace, TwinInstance) and the caller's runtimes`,
|
|
104
|
+
twinDeclarations: `${DOMAIN_ROWS} (TwinInstance, TwinSpace) and the caller's runtime`,
|
|
105
|
+
twinItemLineage: `${DOMAIN_ROWS} (TwinInstance, TwinEvent)`,
|
|
106
|
+
twinModelEntities: `${DOMAIN_ROWS} (TwinInstance and the entity tables) or the caller's runtime`,
|
|
107
|
+
twinModelItem: `${DOMAIN_ROWS} (TwinInstance and the entity tables) or the caller's runtime`,
|
|
108
|
+
twinModelSummary: `${DOMAIN_ROWS} (TwinInstance, TwinReference, TwinEvent) or the caller's runtime`,
|
|
109
|
+
twinModelTree: `${DOMAIN_ROWS} (TwinInstance)`,
|
|
110
|
+
|
|
111
|
+
/* Spaces and targets. */
|
|
112
|
+
twinAreas: `${DOMAIN_ROWS} (TwinArea)`,
|
|
113
|
+
twinSpace: `${DOMAIN_ROWS} (TwinSpace, representations, areas)`,
|
|
114
|
+
twinSpaceIntegrity: `${DOMAIN_ROWS} (every space table)`,
|
|
115
|
+
twinSpaces: `${DOMAIN_ROWS} (TwinSpace, representations, TwinInstance)`,
|
|
116
|
+
twinTargets: `${DOMAIN_ROWS} (TwinTarget)`,
|
|
117
|
+
|
|
118
|
+
/* Live health and forecasting — computed from the caller's runtime. */
|
|
119
|
+
twinAttentionDigest: `${DOMAIN_RUNTIME} after an ownership check`,
|
|
120
|
+
twinAttentions: `${DOMAIN_RUNTIME} after an ownership check`,
|
|
121
|
+
twinForecast: `${DOMAIN_RUNTIME} (forks the caller's kernel)`,
|
|
122
|
+
twinForecastGapTrend: `${DOMAIN_RUNTIME} for the caller's co-located twins`,
|
|
123
|
+
twinIngestHealth: `${DOMAIN_RUNTIME} after an ownership check; raw samples need more`,
|
|
124
|
+
twinIngestHistory: `${DOMAIN_ROWS} (TwinIngestWindow) and the caller's runtime`,
|
|
125
|
+
twinMetrics: `${DOMAIN_RUNTIME} after an ownership check`,
|
|
126
|
+
twinMetricsAll: `${DOMAIN_RUNTIME} for every runtime of the caller's domain`
|
|
127
|
+
},
|
|
128
|
+
Subscription: {
|
|
129
|
+
twinState: 'the caller must own the instance to subscribe (ADR-0027 read)',
|
|
130
|
+
twinReferenceProgress: 'import progress for one source (ADR-0027 read)'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const req = createRequire(import.meta.url)
|
|
135
|
+
let schemaPromise: Promise<any> | undefined
|
|
136
|
+
|
|
137
|
+
function schema(): Promise<any> {
|
|
138
|
+
const stale = staleResolvers()
|
|
139
|
+
if (stale.length) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`dist-server is older than ${stale.length} resolver source(s), so the schema below is not the one served:\n ` +
|
|
142
|
+
stale.join('\n ') +
|
|
143
|
+
'\nBuild first: yarn workspace @things-factory/headless-twin build:server'
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
schemaPromise ??= (async () => {
|
|
147
|
+
req('reflect-metadata')
|
|
148
|
+
const { buildSchema } = req('type-graphql')
|
|
149
|
+
const { schema: declared } = req(join(DIST, 'service/index.js'))
|
|
150
|
+
const { privilegeDirectiveResolver } = req('@things-factory/auth-base/dist-server/service/privilege/privilege-directive.js')
|
|
151
|
+
/* Subscriptions need a PubSub to build. Nothing is published here — only the shape is read. */
|
|
152
|
+
const pubSub = { publish() {}, subscribe: () => (async function* () {})() }
|
|
153
|
+
const built = await buildSchema({ resolvers: declared.resolverClasses, validate: false, pubSub })
|
|
154
|
+
privilegeDirectiveResolver(built)
|
|
155
|
+
return built
|
|
156
|
+
})()
|
|
157
|
+
return schemaPromise
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const privilegeOfField = (t: string, f: string) =>
|
|
161
|
+
req('@things-factory/auth-base/dist-server/service/privilege/privilege-directive.js').privilegeOfField(t, f)
|
|
162
|
+
|
|
163
|
+
async function fieldsOf(typeName: 'Query' | 'Mutation' | 'Subscription'): Promise<string[]> {
|
|
164
|
+
const s = await schema()
|
|
165
|
+
const type = typeName === 'Query' ? s.getQueryType() : typeName === 'Mutation' ? s.getMutationType() : s.getSubscriptionType()
|
|
166
|
+
return Object.keys(type?.getFields() ?? {})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function openFields(typeName: 'Query' | 'Mutation' | 'Subscription'): Promise<string[]> {
|
|
170
|
+
return (await fieldsOf(typeName)).filter(f => {
|
|
171
|
+
const gate = privilegeOfField(typeName, f)
|
|
172
|
+
return gate.known && gate.gate === null
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test('every open read is one somebody chose, with how it stays in the caller\'s tenant', async () => {
|
|
177
|
+
for (const t of ['Query', 'Subscription'] as const) {
|
|
178
|
+
assert.deepEqual((await openFields(t)).sort(), Object.keys(OPEN_BY_DECISION[t]).sort(), `${t}: open fields ≠ OPEN_BY_DECISION`)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('no write is open — ADR-0027 gates every mutation', async () => {
|
|
183
|
+
assert.deepEqual(await openFields('Mutation'), [])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('the walk reaches the whole schema, not a handful of fields', async () => {
|
|
187
|
+
/* If the resolver list shrank to nothing, the two tests above would pass by having nothing to look at. */
|
|
188
|
+
assert.ok((await fieldsOf('Query')).length > 50)
|
|
189
|
+
assert.ok((await fieldsOf('Mutation')).length > 40)
|
|
190
|
+
assert.ok((await fieldsOf('Subscription')).length >= 2)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('an undeclared field is told apart from one the schema does not have', async () => {
|
|
194
|
+
await schema()
|
|
195
|
+
/* `known: false` must never read as open — that is how a typo becomes a gate that is not there. */
|
|
196
|
+
assert.deepEqual(privilegeOfField('Query', 'noSuchField'), { known: false })
|
|
197
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ Two tenants, one twin name — each subscriber receives its own tenant's messages and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The same instanceId (and the same reference source) in two tenants is the expected case: source
|
|
5
|
+
* names such as `sap-ewm-1710` are vocabulary tenants share (`runtime-key.ts`). Until 2026-09-24 both
|
|
6
|
+
* channels filtered on that name alone, so tenant B's subscriber received A's deltas, and the snapshot
|
|
7
|
+
* sent when A subscribed went to B as well.
|
|
8
|
+
*
|
|
9
|
+
* This counts what arrives. A filter test alone would not see a sender that builds its own payload
|
|
10
|
+
* without a domain, so the last two tests read the source: every sender goes through the channel's
|
|
11
|
+
* payload builder, and nothing else names the topic.
|
|
12
|
+
*/
|
|
13
|
+
import { test } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
16
|
+
import { dirname, join, relative } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
import { TWIN_STATE_TOPIC, twinStateFilter, twinStatePayload } from '../server/engine/twin-state-channel.ts'
|
|
20
|
+
import {
|
|
21
|
+
REFERENCE_PROGRESS_TOPIC,
|
|
22
|
+
referenceProgressFilter,
|
|
23
|
+
referenceProgressPayload
|
|
24
|
+
} from '../server/service/reference/reference-progress-channel.ts'
|
|
25
|
+
|
|
26
|
+
const A = 'domain-a'
|
|
27
|
+
const B = 'domain-b'
|
|
28
|
+
const SHARED = 'sap-ewm-1710'
|
|
29
|
+
|
|
30
|
+
/* A pubsub reduced to what matters here: every published payload, and a subscriber's filter over them. */
|
|
31
|
+
function bus() {
|
|
32
|
+
const sent: { topic: string; payload: any }[] = []
|
|
33
|
+
return {
|
|
34
|
+
publish: (topic: string, payload: any) => void sent.push({ topic, payload }),
|
|
35
|
+
received: (topic: string, keep: (p: any) => boolean) => sent.filter(m => m.topic === topic).map(m => m.payload).filter(keep)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('twin-state: B receives none of A\'s snapshot or deltas for a twin of the same name', () => {
|
|
40
|
+
const b = bus()
|
|
41
|
+
/* A subscribes (its snapshot goes out), then both tenants' twins tick. */
|
|
42
|
+
b.publish(TWIN_STATE_TOPIC, twinStatePayload(A, { instanceId: SHARED, kind: 'snapshot', revision: 7, payload: { owner: 'A' } }))
|
|
43
|
+
b.publish(TWIN_STATE_TOPIC, twinStatePayload(A, { instanceId: SHARED, kind: 'delta', revision: 8 }))
|
|
44
|
+
b.publish(TWIN_STATE_TOPIC, twinStatePayload(B, { instanceId: SHARED, kind: 'delta', revision: 3 }))
|
|
45
|
+
b.publish(TWIN_STATE_TOPIC, twinStatePayload(B, { instanceId: 'other-twin', kind: 'delta', revision: 1 }))
|
|
46
|
+
|
|
47
|
+
const toB = b.received(TWIN_STATE_TOPIC, twinStateFilter(B, SHARED))
|
|
48
|
+
assert.deepEqual(toB.map(p => [p.domainId, p.twinState.kind, p.twinState.revision]), [[B, 'delta', 3]])
|
|
49
|
+
|
|
50
|
+
const toA = b.received(TWIN_STATE_TOPIC, twinStateFilter(A, SHARED))
|
|
51
|
+
assert.deepEqual(toA.map(p => [p.twinState.kind, p.twinState.revision]), [['snapshot', 7], ['delta', 8]])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('twin-reference-progress: B receives none of A\'s import progress for a source of the same name', () => {
|
|
55
|
+
const b = bus()
|
|
56
|
+
b.publish(REFERENCE_PROGRESS_TOPIC, referenceProgressPayload(A, { source: SHARED, step: 'master', done: 1, error: 'A: connection refused' }))
|
|
57
|
+
b.publish(REFERENCE_PROGRESS_TOPIC, referenceProgressPayload(B, { source: SHARED, step: 'master', done: 2 }))
|
|
58
|
+
|
|
59
|
+
const toB = b.received(REFERENCE_PROGRESS_TOPIC, referenceProgressFilter(B, SHARED))
|
|
60
|
+
assert.deepEqual(toB.map(p => [p.domainId, p.twinReferenceProgress.done]), [[B, 2]])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a payload cannot be built without a domain', () => {
|
|
64
|
+
assert.throws(() => twinStatePayload('', { instanceId: SHARED, kind: 'delta' }), /domainId is required/)
|
|
65
|
+
assert.throws(() => referenceProgressPayload('', { source: SHARED }), /domainId is required/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/* ── every sender goes through the channel ────────────────────────────────── */
|
|
69
|
+
|
|
70
|
+
const SERVER = join(dirname(fileURLToPath(import.meta.url)), '..', 'server')
|
|
71
|
+
const CHANNELS = new Set(['engine/twin-state-channel.ts', 'service/reference/reference-progress-channel.ts'])
|
|
72
|
+
|
|
73
|
+
function serverSources(): { path: string; code: string }[] {
|
|
74
|
+
const out: { path: string; code: string }[] = []
|
|
75
|
+
const walk = (dir: string) => {
|
|
76
|
+
for (const name of readdirSync(dir)) {
|
|
77
|
+
const p = join(dir, name)
|
|
78
|
+
if (statSync(p).isDirectory()) walk(p)
|
|
79
|
+
else if (p.endsWith('.ts')) {
|
|
80
|
+
/* Comments describe the old shape; count code only. */
|
|
81
|
+
const code = readFileSync(p, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
82
|
+
out.push({ path: relative(SERVER, p), code })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
walk(SERVER)
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('no sender builds a channel payload by hand, and nothing names the topics but the channels', () => {
|
|
91
|
+
const offenders: string[] = []
|
|
92
|
+
for (const { path, code } of serverSources()) {
|
|
93
|
+
if (CHANNELS.has(path)) continue
|
|
94
|
+
if (/\btwinState\s*:\s*\{/.test(code)) offenders.push(`${path}: builds { twinState: … } by hand`)
|
|
95
|
+
if (/\btwinReferenceProgress\s*:\s*\{/.test(code)) offenders.push(`${path}: builds { twinReferenceProgress: … } by hand`)
|
|
96
|
+
if (/['"`]twin-state['"`]/.test(code)) offenders.push(`${path}: names 'twin-state'`)
|
|
97
|
+
if (/['"`]twin-reference-progress['"`]/.test(code)) offenders.push(`${path}: names 'twin-reference-progress'`)
|
|
98
|
+
}
|
|
99
|
+
assert.deepEqual(offenders, [])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('the senders are really there — the scan above is not passing by finding nothing', () => {
|
|
103
|
+
const callers = serverSources()
|
|
104
|
+
.filter(s => !CHANNELS.has(s.path))
|
|
105
|
+
.map(s => s.code)
|
|
106
|
+
.join('\n')
|
|
107
|
+
/* engine: runtime subscription + flush; the subscription's first snapshot. */
|
|
108
|
+
assert.equal((callers.match(/twinStatePayload\(/g) ?? []).length, 3, 'twinStatePayload call sites')
|
|
109
|
+
/* resync and import. */
|
|
110
|
+
assert.equal((callers.match(/referenceProgressPayload\(/g) ?? []).length, 2, 'referenceProgressPayload call sites')
|
|
111
|
+
})
|