import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' import { ObjectType, Field, ID } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' import { User } from '@things-factory/auth-base' import { modelColumnTransformer } from '../../engine/model-vocabulary.js' /* * TwinInstance — 트윈 인스턴스 레지스트리(재부팅 복구의 앵커). * 복구(DB→replay)는 model(마스터 청사진)가 있어야 이벤트를 상태로 다시 계산할 수 있다 → model·kind 를 영속. * 이벤트(TwinEvent)와 달리 가변(status 갱신) — 인스턴스의 "현재 등록 상태". * model 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통). * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.) */ @Entity() @Index('ix_twin_instance_0', (e: TwinInstance) => [e.domain, e.instanceId], { unique: true }) @Index('ix_twin_instance_1', (e: TwinInstance) => [e.domain, e.spaceId]) // 공간→인스턴스 1급 질의(공간 중심 존재론) @ObjectType({ description: 'Registry of a hosted twin runtime instance — anchors reboot recovery (model + kind).' }) export class TwinInstance { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the registry record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinInstance) => e.domain) domainId?: string @Column() @Field({ description: 'Twin runtime instance id (unique within a domain).' }) instanceId: string /** * **사람이 부르는 이름** — 업무키(`instanceId`)와 나란히 두는 표시명(프레임워크 엔티티 규약). * * ── 왜 업무키와 이름을 나눠 두나 ───────────────────────────────────────────── * `instanceId` 는 URL·저널·구독이 전부 쓰는 **계약**이라 바꿀 수 없다. 반대로 이름은 바뀌고, * 중복되고(사이트 둘에 "도장" 이 있는 것이 정상), 언어마다 다르다. 둘을 한 컬럼에 담으면 개명이 * 곧 다른 트윈이 되고 재동기가 끊긴다 — 그래서 나눈다(이 저장소에서 식별자가 밖에서 오는 것은 * 전부 이 규약을 쓴다: `spaceId`·`locationId`·`equipmentId`·`operationKey`). * * 나눴으면 **이름을 반드시 함께 둬야 한다.** 이 자리가 없던 동안 사이트 이름은 model 안에만 있어서 * 목록이 `crew-two` 만 보였다 — 나누기만 하고 절반을 빼먹으면 화면에는 id 만 남는다. * * 원본이 이름을 말하지 않으면 비운다(id 를 베끼지 않는다). */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Human name of this twin (the site name from its master). Empty when the source does not name it — never copied from instanceId.' }) name?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Longer description when the source provides one.' }) description?: string /* 어느 도메인 커널이 이 트윈을 운영하는가. 커널 없는 트윈은 없으므로 비어 있을 수 없다 (`TwinEngine.start`·`register` 모두 필수 인자로 받는다). */ @Column() @Field({ description: 'Domain kernel kind: wms | yms | mes.' }) kind: string /* * **재기동 정책** — 이 트윈은 다시 세워질 때 자기 과거를 어떻게 대하나(ADR-0029 §2). * * 'resync' = 외부 실물에서 다시 읽는다(원본이 진실이므로 우리 과거를 이어 붙이지 않는다 → startLive). * 'resume' = 저널을 이어 세운다(이 트윈이 낳은 타임라인이 곧 사실이다). * 'reset' = 저널을 비우고 씨앗부터 다시 돌린다(재현 가능한 실험 — 백지 시작이 선언된 거동이다). * * ── 왜 트윈에 남았나 (옛 이름 `realityMode`) ──────────────────────────────── * 「현실이 어디서 오나」는 ADR-0029 가 **원본의 종류**로 옮긴 물음이다(`TwinReference.adapterType`) — * 트윈은 한 종류이고 언제나 원본을 반영한다. 그런데 코드가 이 축으로 실제로 가른 것은 **재기동 거동 * 하나**였고, 그것은 원본의 성질이 아니라 **트윈과 자기 과거의 관계**다: 같은 시뮬레이터 원본을 보는 두 * 트윈이 하나는 이어가고 하나는 매번 처음부터 재현할 수 있다. 그래서 축은 남고 이름만 바뀌었다. * * **비어 있을 수 없고, 기본값도 없다.** 예전에는 미선언이 알리지 않고 `sim-experiment`(=저널 초기화)로 * 떨어졌다 — 값이 하나 어긋나는 순간 이력을 지우는 길이다. 이제 읽는 쪽이 **오류를 낸다** * (`engine/restart-policy.ts`). 선언은 프로비저닝하는 쪽의 몫이다. */ @Column() @Field({ description: "Restart policy: 'resync' (read again from the external source) | 'resume' (continue this twin's own journal) | 'reset' (wipe and replay from the seed). No default — an unspecified policy is refused." }) restartPolicy: string /* 읽을 때 어휘 세대를 흡수한다(`nodes`/`movers`/`equipmentList` → `locations`/`equipment`). (vocabulary-guard: allow — 세대 이름 없이는 설명할 수 없다.) 자리마다 리더를 쓰게 하면 다음에 추가되는 자리가 또 빠지고, 빠진 것은 오류가 아니라 **빈 공장**이라 들어온 것이 없다(2026-08-06: 모델러 엔티티 목록이 통째로 비었다). 상세 `engine/model-vocabulary.ts`. */ @Column({ type: 'simple-json', nullable: true, transformer: modelColumnTransformer }) @Field(type => ScalarObject, { nullable: true, description: 'TwinModelDef blueprint (locations/equipment) required to replay events into state. Older stored boards used nodes/movers/equipmentList; they are normalised on read so consumers see one vocabulary.' }) // vocabulary-guard: allow — 옛 어휘를 흡수하는 것이 이 코드의 일이다(세대 이름을 적어야 흡수할 수 있다) model?: any /* * 로컬 선언 겹 — **구조는 원천이, 수는 우리가.** * * 요금 단가·계약 정보·축전지 충전율처럼 원 시스템이 모르는 값을 넣는 자리다. `model` 에 직접 쓰지 * 않는 이유는 그것이 **원천의 사본**이기 때문이다: 다시 읽기가 사본을 새로 그리면 직접 쓴 값은 알리지 않고 * 사라진다(요금이 사라지면 금액도 사라지고, 사용자는 이유를 알 수 없다). * * 그래서 여기 남기고, 모델을 그릴 때마다 그 위에 얹는다(`engine/local-declarations`). 얹힌 값에는 * `source: 'local'` 이 붙는다 — 커널 계약의 `PropertySource` 축(`reference` ↔ `local`)이고, 「밖에서 * 참조해 온 값이냐, 이 안에서 정한 값이냐」에 답한다. * * **이름에 장소 낱말을 쓰지 않는다**(옛 이름 `siteDeclarations`): 이 코드에서 `site` 는 **외부 * 시스템이 부르는 사업장**이고(SAP `P3`·Oracle `W01` 이 같은 현장), 여기 담기는 것은 그 외부 시스템이 * **모르는** 값이다 — 이름이 내용과 정반대였다. `space` 도 아니다: 그것은 「어디」에 답하고 이 축은 * 「누가 말했나」에 답한다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: "Locally-declared values layered on top of the reference-derived model (tariff, contract, state of charge) — values whose authority sits here, not in the source system. Kept apart from `model` so a resync cannot silently drop them." }) localDeclarations?: any @Column({ nullable: true }) @Field({ nullable: true, description: 'Owning space id (co-location anchor). Promoted from model.spaceId — first-class "space → its twins" query (space-centered ontology).' }) spaceId?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Owning area id within the space (optional scope). Promoted from model.areaId.' }) areaId?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Lifecycle status: running | stopped.' }) status?: string /* * purpose — 이 인스턴스가 운영 트윈인지, 격리된 벤치 사본인지 1급 선언(이름 접두사가 아니라 속성으로 구별). * 'bench' 는 규모/부하 실험용 격리 사본 — 목록·정리·안전 가드가 이 속성을 키로 동작(운영 트윈 무오염 보장). * * **비어 있을 수 없다.** 프로비저닝이 `register` 에서 'operational' 로 각인하고, 벤치 사본만 * `setPurpose` 로 뒤집는다. 예전에는 비워 둔 채 읽는 쪽에서 메웠는데, 그러면 "운영이라고 선언된 것" * 과 "아무도 말하지 않은 것" 이 같은 값으로 보여서 벤치 격리가 추측에 기대게 된다. */ @Column() @Field({ description: "Instance purpose: 'operational' (real twin) | 'bench' (isolated copy for scale/load experiments)." }) purpose: string @Column({ nullable: true }) @Field({ nullable: true, description: 'For a bench copy: the source spaceId it was copied from (provenance).' }) copyOf?: string /** * **이 트윈을 다시 읽는 방법** — 어떤 원본에서 나왔나(`MasterOrigin`). * * 트윈은 원본을 비출 뿐이므로(ADR-0028) 원본이 달라지면 다시 읽어야 한다. 그런데 이 자리가 없던 * 동안 트윈은 **자기가 무엇에서 나왔는지 몰랐다** — 어댑터로 들어온 트윈은 `TwinReference` 행이 * 있었지만 템플릿으로 세운 트윈에는 아무 표식도 없었고, 그래서 첫날의 모습에 영원히 머무는 * 트윈이 만들어졌다. 원천이 무엇이든 여기 한 자리에 남긴다. * * **비어 있을 수 있다** — 이 자리가 생기기 전에 만들어진 트윈이 그렇다. 그때는 "다시 읽을 수 * 없다" 가 사실이므로 화면이 그대로 말한다(짐작해 채우지 않는다). */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: "How this twin's master can be produced again — {kind:'reference', source} or {kind:'template', templateId, params}. Absent means this twin cannot be re-read from its source." }) origin?: any /* * `revision` 컬럼은 **없앴다**(2026-08-08). * * `ADR-0015` 가 "정의만 있고 write 안 됨 — 프로비저닝에서 최신 저널 revision 을 반영하도록 * 채운다" 고 적어 두었는데, 채우지 않는 편이 맞다. 저널의 최신 번호는 **저널이 아는 사실**이고 * (`TwinEngine.list` 가 `MAX(revision)` 으로 낸다), 여기 복제하면 **같은 사실의 집이 둘**이 된다. * 이 세션에만 그 부류를 셋 겪었다(`model.operations` 두 집 · `materials` 두 집 · tenantId 중복). * * 복제한 값은 반드시 어긋난다 — 저널은 틱마다 늘고 이 컬럼은 저장할 때만 갱신되므로, 화면이 * 어느 쪽을 읽느냐로 다른 숫자가 나온다. 그 들어온 것이 없는 어긋남을 만들 이유가 없다. */ @CreateDateColumn() @Field({ nullable: true, description: 'Timestamp when the instance was first registered.' }) createdAt?: Date @UpdateDateColumn() @Field({ nullable: true, description: 'Timestamp when the instance registry row was last updated.' }) updatedAt?: Date /* * 누가 만들고 누가 고쳤나 — **이 행은 사람이 만든다**(프로비저닝은 사용자 행위)이므로 규약의 * 풀세트를 갖는다. 투영 행(자리·설비·공정)은 반대로 인제스트가 그린 것이라 저자가 사람이 아니고, * 그 저자는 `sourceRef`+`ingestedAt` 이 이미 말한다 — 그래서 그쪽에는 두지 않는다. * * 비어 있을 수 있다: 부팅 자동 프로비저닝처럼 **사람이 없는 경로**로 만들어진 행이 그렇다. * 그때 아무 사용자를 적어 넣으면 감사 기록이 거짓이 된다. */ @ManyToOne(type => User, { nullable: true }) @Field(type => User, { nullable: true, description: 'The user who first provisioned this twin. Empty when it was created by a system path (boot provisioning).' }) creator?: User @RelationId((e: TwinInstance) => e.creator) creatorId?: string @ManyToOne(type => User, { nullable: true }) @Field(type => User, { nullable: true, description: 'The user who last changed this twin registry row.' }) updater?: User @RelationId((e: TwinInstance) => e.updater) updaterId?: string }