@things-factory/headless-twin 10.0.0-zeta.7
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 +22 -0
- package/dist-server/engine/twin-engine.js +76 -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 +9 -0
- package/dist-server/service/index.js +26 -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-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 +95 -0
- package/server/index.ts +16 -0
- package/server/routes.ts +5 -0
- package/server/service/index.ts +25 -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-state/index.ts +3 -0
- package/server/service/twin-state/twin-state-subscription.ts +53 -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.7",
|
|
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.3",
|
|
29
|
+
"@things-factory/env": "^10.0.0-zeta.1",
|
|
30
|
+
"@things-factory/shell": "^10.0.0-zeta.3"
|
|
31
|
+
},
|
|
32
|
+
"gitHead": "937be2e53a88f68474ab493dad86b018b3901b81"
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './twin-engine.js'
|
|
@@ -0,0 +1,95 @@
|
|
|
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)만 얇게 래핑.
|
|
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
|
+
|
|
14
|
+
import type { TwinKernel, BoardDef, SubscriptionMessage, TwinRuntime as TwinRuntimeType } from '@operato/twin-kernel'
|
|
15
|
+
|
|
16
|
+
/* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. */
|
|
17
|
+
const { WmsKernel, YmsKernel, MesKernel, TwinRuntime } = require('@operato/twin-kernel')
|
|
18
|
+
|
|
19
|
+
const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel }
|
|
20
|
+
|
|
21
|
+
interface TwinInstance {
|
|
22
|
+
id: string
|
|
23
|
+
domainId: string
|
|
24
|
+
runtime: TwinRuntimeType
|
|
25
|
+
unsub: () => void
|
|
26
|
+
timer?: any
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class TwinEngine {
|
|
30
|
+
static instances: Record<string, TwinInstance> = {}
|
|
31
|
+
static TICK_MS = 1000
|
|
32
|
+
|
|
33
|
+
/** 부팅 훅 — 영속 인스턴스 복구(DB→replay)는 향후. 지금은 명시 start 로 인스턴스 생성. */
|
|
34
|
+
static async bootstrap(): Promise<void> {}
|
|
35
|
+
|
|
36
|
+
/** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + State 스트림 브리지 + 워커 tick. */
|
|
37
|
+
static start(id: string, domainId: string, kind: string, board: BoardDef): TwinInstance {
|
|
38
|
+
if (this.instances[id]) return this.instances[id]
|
|
39
|
+
|
|
40
|
+
const Kernel = KERNELS[kind] ?? WmsKernel
|
|
41
|
+
const kernel: TwinKernel = new Kernel(domainId)
|
|
42
|
+
kernel.loadBoard(board)
|
|
43
|
+
const runtime: TwinRuntimeType = new TwinRuntime(kernel)
|
|
44
|
+
|
|
45
|
+
/* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub 방송(구독 리졸버가 instanceId 필터). */
|
|
46
|
+
const sub = runtime.subscribe((msg: SubscriptionMessage) => {
|
|
47
|
+
pubsub.publish('twin-state', {
|
|
48
|
+
twinState: { instanceId: id, kind: msg.kind, revision: (msg as any).revision, payload: msg }
|
|
49
|
+
})
|
|
50
|
+
/* 영속: 이벤트(delta)만 append-only 저장. 재부팅 복구는 DB→replay(향후). */
|
|
51
|
+
if (msg.kind === 'delta') this.persist(domainId, id, msg).catch(err => console.error('twin persist fail', err))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
|
|
55
|
+
const inst: TwinInstance = { id, domainId, runtime, unsub: () => sub.unsubscribe() }
|
|
56
|
+
this.instances[id] = inst
|
|
57
|
+
|
|
58
|
+
/* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
|
|
59
|
+
inst.timer = setInterval(() => runtime.tick(this.TICK_MS), this.TICK_MS)
|
|
60
|
+
return inst
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static async persist(domainId: string, instanceId: string, msg: any): Promise<void> {
|
|
64
|
+
const repo = getRepository(TwinEvent)
|
|
65
|
+
const e = msg.event
|
|
66
|
+
await repo.save(
|
|
67
|
+
repo.create({
|
|
68
|
+
domain: { id: domainId } as any,
|
|
69
|
+
instanceId,
|
|
70
|
+
tenantId: e?.tenantId,
|
|
71
|
+
eventType: e?.eventType,
|
|
72
|
+
revision: msg.revision,
|
|
73
|
+
eventTime: e?.eventTime,
|
|
74
|
+
payload: e
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static stop(id: string): void {
|
|
80
|
+
const i = this.instances[id]
|
|
81
|
+
if (!i) return
|
|
82
|
+
clearInterval(i.timer)
|
|
83
|
+
i.unsub()
|
|
84
|
+
delete this.instances[id]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static runtime(id: string): TwinRuntimeType | undefined {
|
|
88
|
+
return this.instances[id]?.runtime
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 현재 전체 스냅샷(구독 즉시 발행·resync 용). */
|
|
92
|
+
static snapshot(id: string): any {
|
|
93
|
+
return this.instances[id]?.runtime.resync()
|
|
94
|
+
}
|
|
95
|
+
}
|
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,25 @@
|
|
|
1
|
+
/* EXPORT ENTITY TYPES */
|
|
2
|
+
export * from './twin-event/twin-event.js'
|
|
3
|
+
export * from './twin-state/twin-state-subscription.js'
|
|
4
|
+
|
|
5
|
+
/* IMPORT ENTITIES AND RESOLVERS */
|
|
6
|
+
import { entities as TwinEventEntities } from './twin-event/index.js'
|
|
7
|
+
import { resolvers as TwinStateResolvers } from './twin-state/index.js'
|
|
8
|
+
import { resolvers as TwinControlResolvers } from './twin-control/index.js'
|
|
9
|
+
|
|
10
|
+
export const entities = [
|
|
11
|
+
/* ENTITIES */
|
|
12
|
+
...TwinEventEntities
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export const subscribers = []
|
|
16
|
+
|
|
17
|
+
export const schema = {
|
|
18
|
+
typeDefs: {},
|
|
19
|
+
resolverClasses: [
|
|
20
|
+
/* RESOLVER CLASSES */
|
|
21
|
+
...TwinStateResolvers,
|
|
22
|
+
...TwinControlResolvers
|
|
23
|
+
],
|
|
24
|
+
directives: {}
|
|
25
|
+
}
|
|
@@ -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,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
|
+
}
|