@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.
Files changed (74) hide show
  1. package/README.md +35 -0
  2. package/dist-server/engine/index.d.ts +1 -0
  3. package/dist-server/engine/index.js +5 -0
  4. package/dist-server/engine/index.js.map +1 -0
  5. package/dist-server/engine/twin-engine.d.ts +34 -0
  6. package/dist-server/engine/twin-engine.js +135 -0
  7. package/dist-server/engine/twin-engine.js.map +1 -0
  8. package/dist-server/index.d.ts +3 -0
  9. package/dist-server/index.js +18 -0
  10. package/dist-server/index.js.map +1 -0
  11. package/dist-server/routes.d.ts +0 -0
  12. package/dist-server/routes.js +6 -0
  13. package/dist-server/routes.js.map +1 -0
  14. package/dist-server/service/index.d.ts +10 -0
  15. package/dist-server/service/index.js +33 -0
  16. package/dist-server/service/index.js.map +1 -0
  17. package/dist-server/service/twin-control/index.d.ts +2 -0
  18. package/dist-server/service/twin-control/index.js +6 -0
  19. package/dist-server/service/twin-control/index.js.map +1 -0
  20. package/dist-server/service/twin-control/twin-control-mutation.d.ts +6 -0
  21. package/dist-server/service/twin-control/twin-control-mutation.js +59 -0
  22. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -0
  23. package/dist-server/service/twin-event/index.d.ts +2 -0
  24. package/dist-server/service/twin-event/index.js +6 -0
  25. package/dist-server/service/twin-event/index.js.map +1 -0
  26. package/dist-server/service/twin-event/twin-event.d.ts +13 -0
  27. package/dist-server/service/twin-event/twin-event.js +72 -0
  28. package/dist-server/service/twin-event/twin-event.js.map +1 -0
  29. package/dist-server/service/twin-instance/index.d.ts +2 -0
  30. package/dist-server/service/twin-instance/index.js +6 -0
  31. package/dist-server/service/twin-instance/index.js.map +1 -0
  32. package/dist-server/service/twin-instance/twin-instance.d.ts +13 -0
  33. package/dist-server/service/twin-instance/twin-instance.js +72 -0
  34. package/dist-server/service/twin-instance/twin-instance.js.map +1 -0
  35. package/dist-server/service/twin-journal/index.d.ts +2 -0
  36. package/dist-server/service/twin-journal/index.js +6 -0
  37. package/dist-server/service/twin-journal/index.js.map +1 -0
  38. package/dist-server/service/twin-journal/twin-journal-query.d.ts +5 -0
  39. package/dist-server/service/twin-journal/twin-journal-query.js +64 -0
  40. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -0
  41. package/dist-server/service/twin-lifecycle/index.d.ts +2 -0
  42. package/dist-server/service/twin-lifecycle/index.js +6 -0
  43. package/dist-server/service/twin-lifecycle/index.js.map +1 -0
  44. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +5 -0
  45. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +60 -0
  46. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -0
  47. package/dist-server/service/twin-state/index.d.ts +2 -0
  48. package/dist-server/service/twin-state/index.js +6 -0
  49. package/dist-server/service/twin-state/index.js.map +1 -0
  50. package/dist-server/service/twin-state/twin-state-subscription.d.ts +11 -0
  51. package/dist-server/service/twin-state/twin-state-subscription.js +67 -0
  52. package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -0
  53. package/dist-server/tsconfig.tsbuildinfo +1 -0
  54. package/package.json +33 -0
  55. package/server/engine/index.ts +1 -0
  56. package/server/engine/twin-engine.ts +160 -0
  57. package/server/index.ts +16 -0
  58. package/server/routes.ts +5 -0
  59. package/server/service/index.ts +32 -0
  60. package/server/service/twin-control/index.ts +3 -0
  61. package/server/service/twin-control/twin-control-mutation.ts +41 -0
  62. package/server/service/twin-event/index.ts +3 -0
  63. package/server/service/twin-event/twin-event.ts +55 -0
  64. package/server/service/twin-instance/index.ts +3 -0
  65. package/server/service/twin-instance/twin-instance.ts +55 -0
  66. package/server/service/twin-journal/index.ts +3 -0
  67. package/server/service/twin-journal/twin-journal-query.ts +49 -0
  68. package/server/service/twin-lifecycle/index.ts +3 -0
  69. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +44 -0
  70. package/server/service/twin-state/index.ts +3 -0
  71. package/server/service/twin-state/twin-state-subscription.ts +53 -0
  72. package/server/types/global.d.ts +22 -0
  73. package/things-factory.config.js +2 -0
  74. 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
+ }
@@ -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
+ })
@@ -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,3 @@
1
+ import { TwinControlMutation } from './twin-control-mutation.js'
2
+
3
+ export const resolvers = [TwinControlMutation]
@@ -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,3 @@
1
+ import { TwinEvent } from './twin-event.js'
2
+
3
+ export const entities = [TwinEvent]
@@ -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,3 @@
1
+ import { TwinInstance } from './twin-instance.js'
2
+
3
+ export const entities = [TwinInstance]
@@ -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,3 @@
1
+ import { TwinJournalQuery } from './twin-journal-query.js'
2
+
3
+ export const resolvers = [TwinJournalQuery]
@@ -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,3 @@
1
+ import { TwinLifecycleMutation } from './twin-lifecycle-mutation.js'
2
+
3
+ export const resolvers = [TwinLifecycleMutation]
@@ -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,3 @@
1
+ import { TwinStateSubscription } from './twin-state-subscription.js'
2
+
3
+ export const resolvers = [TwinStateSubscription]
@@ -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 {}
@@ -0,0 +1,2 @@
1
+ /* headless-twin 은 서버-only(헤드리스) 모듈 — 클라이언트/라우트 없음. */
2
+ export default {}
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist-server",
5
+ "baseUrl": "./",
6
+ "skipLibCheck": true
7
+ },
8
+ "include": ["./server/**/*"],
9
+ "exclude": ["**/*.spec.ts"]
10
+ }