@things-factory/headless-twin 10.0.0-zeta.10
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/README.md +35 -0
- package/dist-server/engine/index.d.ts +1 -0
- package/dist-server/engine/index.js +5 -0
- package/dist-server/engine/index.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +34 -0
- package/dist-server/engine/twin-engine.js +135 -0
- package/dist-server/engine/twin-engine.js.map +1 -0
- package/dist-server/index.d.ts +3 -0
- package/dist-server/index.js +18 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/routes.d.ts +0 -0
- package/dist-server/routes.js +6 -0
- package/dist-server/routes.js.map +1 -0
- package/dist-server/service/index.d.ts +10 -0
- package/dist-server/service/index.js +33 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/twin-control/index.d.ts +2 -0
- package/dist-server/service/twin-control/index.js +6 -0
- package/dist-server/service/twin-control/index.js.map +1 -0
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +6 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +59 -0
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -0
- package/dist-server/service/twin-event/index.d.ts +2 -0
- package/dist-server/service/twin-event/index.js +6 -0
- package/dist-server/service/twin-event/index.js.map +1 -0
- package/dist-server/service/twin-event/twin-event.d.ts +13 -0
- package/dist-server/service/twin-event/twin-event.js +72 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -0
- package/dist-server/service/twin-instance/index.d.ts +2 -0
- package/dist-server/service/twin-instance/index.js +6 -0
- package/dist-server/service/twin-instance/index.js.map +1 -0
- package/dist-server/service/twin-instance/twin-instance.d.ts +13 -0
- package/dist-server/service/twin-instance/twin-instance.js +72 -0
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -0
- package/dist-server/service/twin-journal/index.d.ts +2 -0
- package/dist-server/service/twin-journal/index.js +6 -0
- package/dist-server/service/twin-journal/index.js.map +1 -0
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +5 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +64 -0
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -0
- package/dist-server/service/twin-lifecycle/index.d.ts +2 -0
- package/dist-server/service/twin-lifecycle/index.js +6 -0
- package/dist-server/service/twin-lifecycle/index.js.map +1 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +5 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +60 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -0
- package/dist-server/service/twin-state/index.d.ts +2 -0
- package/dist-server/service/twin-state/index.js +6 -0
- package/dist-server/service/twin-state/index.js.map +1 -0
- package/dist-server/service/twin-state/twin-state-subscription.d.ts +11 -0
- package/dist-server/service/twin-state/twin-state-subscription.js +67 -0
- package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +33 -0
- package/server/engine/index.ts +1 -0
- package/server/engine/twin-engine.ts +160 -0
- package/server/index.ts +16 -0
- package/server/routes.ts +5 -0
- package/server/service/index.ts +32 -0
- package/server/service/twin-control/index.ts +3 -0
- package/server/service/twin-control/twin-control-mutation.ts +41 -0
- package/server/service/twin-event/index.ts +3 -0
- package/server/service/twin-event/twin-event.ts +55 -0
- package/server/service/twin-instance/index.ts +3 -0
- package/server/service/twin-instance/twin-instance.ts +55 -0
- package/server/service/twin-journal/index.ts +3 -0
- package/server/service/twin-journal/twin-journal-query.ts +49 -0
- package/server/service/twin-lifecycle/index.ts +3 -0
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +44 -0
- package/server/service/twin-state/index.ts +3 -0
- package/server/service/twin-state/twin-state-subscription.ts +53 -0
- package/server/types/global.d.ts +22 -0
- package/things-factory.config.js +2 -0
- package/tsconfig.json +10 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@things-factory/headless-twin",
|
|
3
|
+
"version": "10.0.0-zeta.10",
|
|
4
|
+
"main": "dist-server/index.js",
|
|
5
|
+
"things-factory": true,
|
|
6
|
+
"author": "heartyoh <heartyoh@hatiolab.com>",
|
|
7
|
+
"description": "Headless digital-twin host — operato-twin kernel(WMS/YMS/MES) 를 things-factory 모듈로 호스팅(State GraphQL subscription + 이벤트 영속 + 워커 tick + command/scenario mutation).",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public",
|
|
11
|
+
"@things-factory:registry": "https://registry.npmjs.org"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/hatiolab/things-factory.git",
|
|
16
|
+
"directory": "packages/headless-twin"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "npm run clean:server && tsc",
|
|
20
|
+
"build:server": "npm run clean:server && tsc",
|
|
21
|
+
"clean:server": "rm -rf dist-server",
|
|
22
|
+
"clean": "npm run clean:server",
|
|
23
|
+
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@operato/twin-kernel": "^0.0.2",
|
|
28
|
+
"@things-factory/auth-base": "^10.0.0-zeta.10",
|
|
29
|
+
"@things-factory/env": "^10.0.0-zeta.10",
|
|
30
|
+
"@things-factory/shell": "^10.0.0-zeta.10"
|
|
31
|
+
},
|
|
32
|
+
"gitHead": "66f0cf11f6a6ddc4ad756a55fa13b26a55d89fd4"
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './twin-engine.js'
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* TwinEngine — operato-twin 커널 인스턴스를 things-factory 위에서 호스팅.
|
|
3
|
+
* integration-base 의 ScenarioEngine 패턴 미러: 메모리 인스턴스 맵 + 백그라운드 tick + pubsub 상태 발행.
|
|
4
|
+
*
|
|
5
|
+
* 커널(zero-dep, ESM+CJS 이중배포)은 CJS require 로 로드(Node 20 CommonJS 소비 — require(ESM) 회피).
|
|
6
|
+
* 커널은 결정적 논리 연산 코어 — 이 host 가 전송(pubsub)·영속(TypeORM)·스케줄(tick)·복구(replay)만 얇게 래핑.
|
|
7
|
+
* (설계 SoT: operato-twin/design/integration/things-factory-host.md)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { pubsub, getRepository } from '@things-factory/shell'
|
|
11
|
+
|
|
12
|
+
import { TwinEvent } from '../service/twin-event/twin-event.js'
|
|
13
|
+
import { TwinInstance } from '../service/twin-instance/twin-instance.js'
|
|
14
|
+
|
|
15
|
+
import type { TwinKernel, BoardDef, SubscriptionMessage, TwinRuntime as TwinRuntimeType } from '@operato/twin-kernel'
|
|
16
|
+
|
|
17
|
+
/* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
|
|
18
|
+
const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, replay } = require('@operato/twin-kernel')
|
|
19
|
+
|
|
20
|
+
const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel }
|
|
21
|
+
|
|
22
|
+
/* 인메모리 라이브 런타임 홀더(영속 엔티티 TwinInstance 와 구분). */
|
|
23
|
+
interface InstanceRuntime {
|
|
24
|
+
id: string
|
|
25
|
+
domainId: string
|
|
26
|
+
runtime: TwinRuntimeType
|
|
27
|
+
unsub: () => void
|
|
28
|
+
timer?: any
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TwinEngine {
|
|
32
|
+
static instances: Record<string, InstanceRuntime> = {}
|
|
33
|
+
/** 라이브 런타임이 없을 때(복구 후 미기동) 저널에서 재구성한 상태 캐시. */
|
|
34
|
+
static recovered: Record<string, any> = {}
|
|
35
|
+
static TICK_MS = 1000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 부팅 훅 — DB 에 status='running' 으로 남은 인스턴스를 저널에서 상태 재구성(DB→replay).
|
|
39
|
+
* 라이브 tick 재개(sim 결정적 re-run / live 인제스트 지속)는 후속. 여기선 상태 복원 + 노출.
|
|
40
|
+
*/
|
|
41
|
+
static async bootstrap(): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
const rows = await getRepository(TwinInstance).find({ where: { status: 'running' } })
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
if (!row.domainId || !row.instanceId) continue
|
|
46
|
+
const state = await this.recover(row.domainId, row.instanceId).catch(() => null)
|
|
47
|
+
if (state) {
|
|
48
|
+
this.recovered[row.instanceId] = { revision: state.revision, state }
|
|
49
|
+
console.log(`[twin-engine] recovered "${row.instanceId}" from journal → revision ${state.revision}.`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('[twin-engine] recovery scan failed', err)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
|
|
58
|
+
static start(id: string, domainId: string, kind: string, board: BoardDef): InstanceRuntime {
|
|
59
|
+
if (this.instances[id]) return this.instances[id]
|
|
60
|
+
|
|
61
|
+
const Kernel = KERNELS[kind] ?? WmsKernel
|
|
62
|
+
const kernel: TwinKernel = new Kernel(domainId)
|
|
63
|
+
kernel.loadBoard(board)
|
|
64
|
+
const runtime: TwinRuntimeType = new TwinRuntime(kernel)
|
|
65
|
+
|
|
66
|
+
/* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub 방송(구독 리졸버가 instanceId 필터). */
|
|
67
|
+
const sub = runtime.subscribe((msg: SubscriptionMessage) => {
|
|
68
|
+
pubsub.publish('twin-state', {
|
|
69
|
+
twinState: { instanceId: id, kind: msg.kind, revision: (msg as any).revision, payload: msg }
|
|
70
|
+
})
|
|
71
|
+
/* 영속: 이벤트(delta)만 append-only 저장. 재부팅 시 이 저널을 replay 해 복구. */
|
|
72
|
+
if (msg.kind === 'delta') this.persist(domainId, id, msg).catch(err => console.error('twin persist fail', err))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
|
|
76
|
+
const inst: InstanceRuntime = { id, domainId, runtime, unsub: () => sub.unsubscribe() }
|
|
77
|
+
this.instances[id] = inst
|
|
78
|
+
delete this.recovered[id] // 라이브가 우선
|
|
79
|
+
|
|
80
|
+
/* 복구 앵커: 레지스트리에 board/kind/status 영속(재부팅 시 이게 있어야 replay 가능). */
|
|
81
|
+
this.register(domainId, id, kind, board).catch(err => console.error('twin register fail', err))
|
|
82
|
+
|
|
83
|
+
/* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
|
|
84
|
+
inst.timer = setInterval(() => runtime.tick(this.TICK_MS), this.TICK_MS)
|
|
85
|
+
return inst
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 레지스트리 upsert(도메인+instanceId 유니크). */
|
|
89
|
+
static async register(domainId: string, instanceId: string, kind: string, board: BoardDef): Promise<void> {
|
|
90
|
+
const repo = getRepository(TwinInstance)
|
|
91
|
+
const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
92
|
+
await repo.save(
|
|
93
|
+
repo.create({
|
|
94
|
+
...(existing ?? {}),
|
|
95
|
+
domain: { id: domainId } as any,
|
|
96
|
+
instanceId,
|
|
97
|
+
kind,
|
|
98
|
+
board,
|
|
99
|
+
status: 'running'
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static async persist(domainId: string, instanceId: string, msg: any): Promise<void> {
|
|
105
|
+
const repo = getRepository(TwinEvent)
|
|
106
|
+
const e = msg.event
|
|
107
|
+
await repo.save(
|
|
108
|
+
repo.create({
|
|
109
|
+
domain: { id: domainId } as any,
|
|
110
|
+
instanceId,
|
|
111
|
+
tenantId: e?.tenantId,
|
|
112
|
+
eventType: e?.eventType,
|
|
113
|
+
revision: msg.revision,
|
|
114
|
+
eventTime: e?.eventTime,
|
|
115
|
+
payload: e
|
|
116
|
+
})
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 재부팅 복구 / 시간여행 — DB 저널을 replay 해 상태 재구성.
|
|
122
|
+
* board 는 레지스트리(TwinInstance)에서, 이벤트는 TwinEvent(revision ASC)에서. untilRevision 지정 시 그 시점까지.
|
|
123
|
+
*/
|
|
124
|
+
static async recover(domainId: string, instanceId: string, untilRevision?: number): Promise<any> {
|
|
125
|
+
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
126
|
+
if (!reg?.board) throw new Error(`twin instance "${instanceId}" not registered (no board to replay)`)
|
|
127
|
+
|
|
128
|
+
const rows = await getRepository(TwinEvent).find({
|
|
129
|
+
where: { domain: { id: domainId }, instanceId },
|
|
130
|
+
order: { revision: 'ASC' }
|
|
131
|
+
})
|
|
132
|
+
const events = rows
|
|
133
|
+
.filter(r => untilRevision == null || (r.revision ?? 0) <= untilRevision)
|
|
134
|
+
.map(r => r.payload)
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
|
|
137
|
+
return replay(reg.board as BoardDef, events)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
static async stop(id: string): Promise<void> {
|
|
141
|
+
const i = this.instances[id]
|
|
142
|
+
if (i) {
|
|
143
|
+
clearInterval(i.timer)
|
|
144
|
+
i.unsub()
|
|
145
|
+
delete this.instances[id]
|
|
146
|
+
await getRepository(TwinInstance)
|
|
147
|
+
.update({ domain: { id: i.domainId }, instanceId: id }, { status: 'stopped' })
|
|
148
|
+
.catch(err => console.error('twin deregister fail', err))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
static runtime(id: string): TwinRuntimeType | undefined {
|
|
153
|
+
return this.instances[id]?.runtime
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 현재 전체 스냅샷 — 라이브 우선, 없으면 저널 복구 캐시. */
|
|
157
|
+
static snapshot(id: string): any {
|
|
158
|
+
return this.instances[id]?.runtime.resync() ?? this.recovered[id]
|
|
159
|
+
}
|
|
160
|
+
}
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './engine/index.js'
|
|
2
|
+
export * from './service/index.js'
|
|
3
|
+
|
|
4
|
+
import './routes.js'
|
|
5
|
+
|
|
6
|
+
import { TwinEngine } from './engine/index.js'
|
|
7
|
+
|
|
8
|
+
/* 모듈 부팅 — 영속 인스턴스 복구 훅(향후). 지금은 명시 start(mutation/부팅 설정)로 인스턴스 생성. */
|
|
9
|
+
process.on('bootstrap-module-start' as any, async ({ app, config, client }: any) => {
|
|
10
|
+
try {
|
|
11
|
+
await TwinEngine.bootstrap()
|
|
12
|
+
console.log('Headless Twin host has started.')
|
|
13
|
+
} catch (ex) {
|
|
14
|
+
console.error('Headless Twin host failed to start.', ex)
|
|
15
|
+
}
|
|
16
|
+
})
|
package/server/routes.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/* 헤드리스 트윈은 GraphQL(subscription/mutation)로 동작 — REST 라우터는 미사용(빈 부트스트랩). */
|
|
2
|
+
process.on('bootstrap-module-global-public-route' as any, (app, globalPublicRouter) => {})
|
|
3
|
+
process.on('bootstrap-module-global-private-route' as any, (app, globalPrivateRouter) => {})
|
|
4
|
+
process.on('bootstrap-module-domain-public-route' as any, (app, domainPublicRouter) => {})
|
|
5
|
+
process.on('bootstrap-module-domain-private-route' as any, (app, domainPrivateRouter) => {})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* EXPORT ENTITY TYPES */
|
|
2
|
+
export * from './twin-event/twin-event.js'
|
|
3
|
+
export * from './twin-instance/twin-instance.js'
|
|
4
|
+
export * from './twin-state/twin-state-subscription.js'
|
|
5
|
+
|
|
6
|
+
/* IMPORT ENTITIES AND RESOLVERS */
|
|
7
|
+
import { entities as TwinEventEntities } from './twin-event/index.js'
|
|
8
|
+
import { entities as TwinInstanceEntities } from './twin-instance/index.js'
|
|
9
|
+
import { resolvers as TwinStateResolvers } from './twin-state/index.js'
|
|
10
|
+
import { resolvers as TwinControlResolvers } from './twin-control/index.js'
|
|
11
|
+
import { resolvers as TwinLifecycleResolvers } from './twin-lifecycle/index.js'
|
|
12
|
+
import { resolvers as TwinJournalResolvers } from './twin-journal/index.js'
|
|
13
|
+
|
|
14
|
+
export const entities = [
|
|
15
|
+
/* ENTITIES */
|
|
16
|
+
...TwinEventEntities,
|
|
17
|
+
...TwinInstanceEntities
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export const subscribers = []
|
|
21
|
+
|
|
22
|
+
export const schema = {
|
|
23
|
+
typeDefs: {},
|
|
24
|
+
resolverClasses: [
|
|
25
|
+
/* RESOLVER CLASSES */
|
|
26
|
+
...TwinStateResolvers,
|
|
27
|
+
...TwinControlResolvers,
|
|
28
|
+
...TwinLifecycleResolvers,
|
|
29
|
+
...TwinJournalResolvers
|
|
30
|
+
],
|
|
31
|
+
directives: {}
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Arg, Mutation, Resolver } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { ScalarObject } from '@things-factory/shell'
|
|
4
|
+
|
|
5
|
+
import { TwinEngine } from '../../engine/index.js'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* 트윈 Command·Scenario 채널 — 상향 mutation.
|
|
9
|
+
* Command: runtime.dispatch(행위). Scenario: runtime.scenario.load/start/pause(예측 자극).
|
|
10
|
+
* (State 하향은 twin-state subscription. tick 은 워커.)
|
|
11
|
+
*/
|
|
12
|
+
@Resolver()
|
|
13
|
+
export class TwinControlMutation {
|
|
14
|
+
/** Command 채널 — 오더 hold/resume/release 등. 커널 dispatch 로 위임, ack 반환. */
|
|
15
|
+
@Mutation(returns => Boolean, { description: 'dispatch a twin command (order.hold/resume/release …)' })
|
|
16
|
+
async dispatchTwinCommand(
|
|
17
|
+
@Arg('instanceId') instanceId: string,
|
|
18
|
+
@Arg('command', type => ScalarObject) command: any
|
|
19
|
+
): Promise<boolean> {
|
|
20
|
+
const runtime = TwinEngine.runtime(instanceId)
|
|
21
|
+
if (!runtime) return false
|
|
22
|
+
const ack = runtime.dispatch(command)
|
|
23
|
+
return !!ack?.accepted
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Scenario 채널 — 생성기 시나리오 제어(load/start/pause). */
|
|
27
|
+
@Mutation(returns => Boolean, { description: 'control twin scenario (load|start|pause)' })
|
|
28
|
+
async controlTwinScenario(
|
|
29
|
+
@Arg('instanceId') instanceId: string,
|
|
30
|
+
@Arg('action') action: string,
|
|
31
|
+
@Arg('scenario', type => ScalarObject, { nullable: true }) scenario?: any
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
const runtime = TwinEngine.runtime(instanceId)
|
|
34
|
+
if (!runtime) return false
|
|
35
|
+
if (action === 'load' && scenario) runtime.scenario.load(scenario)
|
|
36
|
+
else if (action === 'start') runtime.scenario.start()
|
|
37
|
+
else if (action === 'pause') runtime.scenario.pause()
|
|
38
|
+
else return false
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
|
|
2
|
+
import { ObjectType, Field, ID, Int } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { Domain, ScalarObject } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* TwinEvent — append-only 트윈 이벤트 저널(EPCIS 이벤트 + 운영 델타).
|
|
8
|
+
* 커널 EventJournal 의 영속 대응 — 상태=이벤트열의 함수(재부팅 시 DB→replay 재구성).
|
|
9
|
+
* append-only 이므로 updater/deletedAt 없음(이력 CRUD 가 아니라 불변 이벤트 스트림).
|
|
10
|
+
* payload 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통, DB-specific JSON 타입 금지).
|
|
11
|
+
* (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
|
|
12
|
+
*/
|
|
13
|
+
@Entity()
|
|
14
|
+
@Index('ix_twin_event_0', (e: TwinEvent) => [e.domain, e.instanceId, e.revision], { unique: false })
|
|
15
|
+
@ObjectType({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' })
|
|
16
|
+
export class TwinEvent {
|
|
17
|
+
@PrimaryGeneratedColumn('uuid')
|
|
18
|
+
@Field(type => ID, { description: 'Unique identifier of the event record.' })
|
|
19
|
+
readonly id: string
|
|
20
|
+
|
|
21
|
+
@ManyToOne(type => Domain)
|
|
22
|
+
@Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })
|
|
23
|
+
domain?: Domain
|
|
24
|
+
|
|
25
|
+
@RelationId((e: TwinEvent) => e.domain)
|
|
26
|
+
domainId?: string
|
|
27
|
+
|
|
28
|
+
@Column({ nullable: true })
|
|
29
|
+
@Field({ nullable: true, description: 'Twin runtime instance id that emitted the event.' })
|
|
30
|
+
instanceId?: string
|
|
31
|
+
|
|
32
|
+
@Column({ nullable: true })
|
|
33
|
+
@Field({ nullable: true, description: 'Kernel tenant id carried on the canonical envelope.' })
|
|
34
|
+
tenantId?: string
|
|
35
|
+
|
|
36
|
+
@Column({ nullable: true })
|
|
37
|
+
@Field({ nullable: true, description: 'Event type: epcis.* | task.status | equipment.status | order.status.' })
|
|
38
|
+
eventType?: string
|
|
39
|
+
|
|
40
|
+
@Column({ type: 'int', nullable: true })
|
|
41
|
+
@Field(type => Int, { nullable: true, description: 'Monotonic state revision at which the event was emitted.' })
|
|
42
|
+
revision?: number
|
|
43
|
+
|
|
44
|
+
@Column({ nullable: true })
|
|
45
|
+
@Field({ nullable: true, description: 'Simulation event time (ISO 8601).' })
|
|
46
|
+
eventTime?: string
|
|
47
|
+
|
|
48
|
+
@Column({ type: 'simple-json', nullable: true })
|
|
49
|
+
@Field(type => ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' })
|
|
50
|
+
payload?: any
|
|
51
|
+
|
|
52
|
+
@CreateDateColumn()
|
|
53
|
+
@Field({ nullable: true, description: 'Wall-clock timestamp when the record was persisted.' })
|
|
54
|
+
createdAt?: Date
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
|
|
2
|
+
import { ObjectType, Field, ID, Int } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { Domain, ScalarObject } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* TwinInstance — 트윈 인스턴스 레지스트리(재부팅 복구의 앵커).
|
|
8
|
+
* 복구(DB→replay)는 board(마스터 청사진)가 있어야 이벤트를 상태로 되짚을 수 있다 → board·kind 를 영속.
|
|
9
|
+
* 이벤트(TwinEvent)와 달리 가변(status/revision 갱신) — 인스턴스의 "현재 등록 상태".
|
|
10
|
+
* board 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통).
|
|
11
|
+
* (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
|
|
12
|
+
*/
|
|
13
|
+
@Entity()
|
|
14
|
+
@Index('ix_twin_instance_0', (e: TwinInstance) => [e.domain, e.instanceId], { unique: true })
|
|
15
|
+
@ObjectType({ description: 'Registry of a hosted twin runtime instance — anchors reboot recovery (board + kind).' })
|
|
16
|
+
export class TwinInstance {
|
|
17
|
+
@PrimaryGeneratedColumn('uuid')
|
|
18
|
+
@Field(type => ID, { description: 'Unique identifier of the registry record.' })
|
|
19
|
+
readonly id: string
|
|
20
|
+
|
|
21
|
+
@ManyToOne(type => Domain)
|
|
22
|
+
@Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })
|
|
23
|
+
domain?: Domain
|
|
24
|
+
|
|
25
|
+
@RelationId((e: TwinInstance) => e.domain)
|
|
26
|
+
domainId?: string
|
|
27
|
+
|
|
28
|
+
@Column()
|
|
29
|
+
@Field({ description: 'Twin runtime instance id (unique within a domain).' })
|
|
30
|
+
instanceId: string
|
|
31
|
+
|
|
32
|
+
@Column({ nullable: true })
|
|
33
|
+
@Field({ nullable: true, description: 'Domain kernel kind: wms | yms | mes.' })
|
|
34
|
+
kind?: string
|
|
35
|
+
|
|
36
|
+
@Column({ type: 'simple-json', nullable: true })
|
|
37
|
+
@Field(type => ScalarObject, { nullable: true, description: 'BoardDef blueprint (nodes/movers) required to replay events into state.' })
|
|
38
|
+
board?: any
|
|
39
|
+
|
|
40
|
+
@Column({ nullable: true })
|
|
41
|
+
@Field({ nullable: true, description: 'Lifecycle status: running | stopped.' })
|
|
42
|
+
status?: string
|
|
43
|
+
|
|
44
|
+
@Column({ type: 'int', nullable: true })
|
|
45
|
+
@Field(type => Int, { nullable: true, description: 'Last persisted state revision observed for this instance.' })
|
|
46
|
+
revision?: number
|
|
47
|
+
|
|
48
|
+
@CreateDateColumn()
|
|
49
|
+
@Field({ nullable: true, description: 'Timestamp when the instance was first registered.' })
|
|
50
|
+
createdAt?: Date
|
|
51
|
+
|
|
52
|
+
@UpdateDateColumn()
|
|
53
|
+
@Field({ nullable: true, description: 'Timestamp when the instance registry row was last updated.' })
|
|
54
|
+
updatedAt?: Date
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'
|
|
2
|
+
import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { getRepository, ScalarObject } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
import { TwinEvent } from '../twin-event/twin-event.js'
|
|
7
|
+
import { TwinEngine } from '../../engine/index.js'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* 저널 읽기 채널 — 상향 query.
|
|
11
|
+
* twinEvents : append-only 이벤트 저널 조회(Ledger/History/Entity360) — instanceId·revision 범위·eventType 필터.
|
|
12
|
+
* twinReplay : 내구 저널을 replay 해 상태 재구성(재부팅 복구·시간여행) — untilRevision 으로 임의 과거.
|
|
13
|
+
* (실시간 스트림은 twinState subscription, 명령은 twin-control mutation. 이건 '질의' 표면.)
|
|
14
|
+
*/
|
|
15
|
+
@Resolver()
|
|
16
|
+
export class TwinJournalQuery {
|
|
17
|
+
@Query(returns => [TwinEvent], {
|
|
18
|
+
description: 'Read the append-only twin event journal (Ledger / History / audit), filtered and ordered by revision.'
|
|
19
|
+
})
|
|
20
|
+
async twinEvents(
|
|
21
|
+
@Arg('instanceId') instanceId: string,
|
|
22
|
+
@Arg('fromRevision', type => Int, { nullable: true }) fromRevision: number,
|
|
23
|
+
@Arg('toRevision', type => Int, { nullable: true }) toRevision: number,
|
|
24
|
+
@Arg('eventType', { nullable: true }) eventType: string,
|
|
25
|
+
@Arg('limit', type => Int, { nullable: true }) limit: number,
|
|
26
|
+
@Ctx() context: ResolverContext
|
|
27
|
+
): Promise<TwinEvent[]> {
|
|
28
|
+
const where: any = { domain: { id: context.state.domain.id }, instanceId }
|
|
29
|
+
if (eventType) where.eventType = eventType
|
|
30
|
+
if (fromRevision != null && toRevision != null) where.revision = Between(fromRevision, toRevision)
|
|
31
|
+
else if (fromRevision != null) where.revision = MoreThanOrEqual(fromRevision)
|
|
32
|
+
else if (toRevision != null) where.revision = LessThanOrEqual(toRevision)
|
|
33
|
+
|
|
34
|
+
/* 최신순(DESC) 기본 — Ledger/recent 뷰. 범위 필터로 시간여행 구간 조회도 가능. */
|
|
35
|
+
return getRepository(TwinEvent).find({ where, order: { revision: 'DESC' }, take: limit ?? 200 })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Query(returns => ScalarObject, {
|
|
39
|
+
nullable: true,
|
|
40
|
+
description: 'Reconstruct twin state from the durable journal (DB → replay). untilRevision for time-travel; omit for latest.'
|
|
41
|
+
})
|
|
42
|
+
async twinReplay(
|
|
43
|
+
@Arg('instanceId') instanceId: string,
|
|
44
|
+
@Arg('untilRevision', type => Int, { nullable: true }) untilRevision: number,
|
|
45
|
+
@Ctx() context: ResolverContext
|
|
46
|
+
): Promise<any> {
|
|
47
|
+
return TwinEngine.recover(context.state.domain.id, instanceId, untilRevision ?? undefined)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { ScalarObject } from '@things-factory/shell'
|
|
4
|
+
|
|
5
|
+
import { TwinEngine } from '../../engine/index.js'
|
|
6
|
+
|
|
7
|
+
import type { BoardDef } from '@operato/twin-kernel'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* 트윈 인스턴스 생명주기 채널 — 프로비저닝(상향).
|
|
11
|
+
* State/Command/Scenario 는 이미 running 인 인스턴스를 다루지만, 이 채널은 인스턴스 자체를 만들고/없앤다.
|
|
12
|
+
* (TwinEngine.start/stop 을 GraphQL 로 노출 — 설계 Tier-1 갭: 커널은 start/stop 을 가졌으나 진입점이 없었다.)
|
|
13
|
+
* board(BoardDef)=구조, scenario=자극 은 분리 — 여기선 구조만 세운다. 자극은 controlTwinScenario 로.
|
|
14
|
+
*/
|
|
15
|
+
@Resolver()
|
|
16
|
+
export class TwinLifecycleMutation {
|
|
17
|
+
@Mutation(returns => Boolean, {
|
|
18
|
+
description: 'Provision and start a twin runtime instance (kind: wms|yms|mes) from a BoardDef, and begin the worker tick.'
|
|
19
|
+
})
|
|
20
|
+
async startTwinInstance(
|
|
21
|
+
@Arg('instanceId') instanceId: string,
|
|
22
|
+
@Arg('kind') kind: string,
|
|
23
|
+
@Arg('board', type => ScalarObject) board: any,
|
|
24
|
+
@Ctx() context: ResolverContext
|
|
25
|
+
): Promise<boolean> {
|
|
26
|
+
TwinEngine.start(instanceId, context.state.domain.id, kind, board as BoardDef)
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Mutation(returns => Boolean, {
|
|
31
|
+
description: 'Stop and dispose a running twin runtime instance by its id.'
|
|
32
|
+
})
|
|
33
|
+
async stopTwinInstance(@Arg('instanceId') instanceId: string): Promise<boolean> {
|
|
34
|
+
await TwinEngine.stop(instanceId)
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Query(returns => [String], {
|
|
39
|
+
description: 'List ids of currently running twin runtime instances.'
|
|
40
|
+
})
|
|
41
|
+
twinInstances(): string[] {
|
|
42
|
+
return Object.keys(TwinEngine.instances)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { filter, pipe } from 'graphql-yoga'
|
|
2
|
+
import { Arg, Field, Int, ObjectType, Resolver, Root, Subscription } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { pubsub, ScalarObject } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
import { TwinEngine } from '../../engine/index.js'
|
|
7
|
+
|
|
8
|
+
/* State 채널 메시지 — snapshot | delta | clock 멀티플렉싱(커널 SubscriptionMessage 대응). */
|
|
9
|
+
@ObjectType({ description: 'Twin State channel message multiplexing snapshot | delta | clock.' })
|
|
10
|
+
export class TwinStateMessage {
|
|
11
|
+
@Field({ description: 'Twin runtime instance id this message belongs to.' })
|
|
12
|
+
instanceId: string
|
|
13
|
+
|
|
14
|
+
@Field({ description: 'Message kind: snapshot | delta | clock.' })
|
|
15
|
+
kind: string
|
|
16
|
+
|
|
17
|
+
@Field(type => Int, { nullable: true, description: 'Monotonic state revision.' })
|
|
18
|
+
revision?: number
|
|
19
|
+
|
|
20
|
+
@Field(type => ScalarObject, { nullable: true, description: 'Payload — snapshot: StateSnapshot / delta: CanonicalEnvelope / clock: { simClockMs }.' })
|
|
21
|
+
payload?: any
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* 트윈 State 구독 — state-register/data-resolver 패턴 미러.
|
|
26
|
+
* 구독 즉시 현재 snapshot 발행(process.nextTick) 후 delta/clock 스트림. instanceId 로 필터.
|
|
27
|
+
*/
|
|
28
|
+
@Resolver()
|
|
29
|
+
export class TwinStateSubscription {
|
|
30
|
+
@Subscription(returns => TwinStateMessage, {
|
|
31
|
+
subscribe: ({ args, context }) => {
|
|
32
|
+
const { instanceId } = args
|
|
33
|
+
|
|
34
|
+
/* 구독 순간 현재 상태를 먼저 발행(late 구독자도 snapshot→delta 순서 보장). */
|
|
35
|
+
process.nextTick(() => {
|
|
36
|
+
const snap = TwinEngine.snapshot(instanceId)
|
|
37
|
+
if (snap) {
|
|
38
|
+
pubsub.publish('twin-state', {
|
|
39
|
+
twinState: { instanceId, kind: 'snapshot', revision: snap.revision, payload: snap }
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return pipe(
|
|
45
|
+
pubsub.subscribe('twin-state'),
|
|
46
|
+
filter((payload: { twinState: TwinStateMessage }) => payload.twinState.instanceId === instanceId)
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
twinState(@Root() payload: { twinState: TwinStateMessage }, @Arg('instanceId') instanceId: string): TwinStateMessage {
|
|
51
|
+
return payload.twinState
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global type definitions for the headless-twin module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Domain } from '@things-factory/shell'
|
|
6
|
+
import { User } from '@things-factory/auth-base'
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
/**
|
|
10
|
+
* GraphQL Resolver Context — available in every @Ctx() decorated parameter.
|
|
11
|
+
*/
|
|
12
|
+
interface ResolverContext {
|
|
13
|
+
state: {
|
|
14
|
+
domain: Domain
|
|
15
|
+
user: User
|
|
16
|
+
[key: string]: any
|
|
17
|
+
}
|
|
18
|
+
[key: string]: any
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export {}
|