import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { ObjectType, Field, ID, Int } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' import { User } from '@things-factory/auth-base' import { modelColumnTransformer } from '../../engine/model-vocabulary.js' /* * TwinStructure — **한 트윈이 거쳐 온 공장 구조들.** 리비전 하나가 그 시절의 공장이다. * * ── 왜 필요한가: 역사를 잃거나 거짓말을 하거나 ────────────────────────────── * 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인 하나를 닫는다. 그런데 지금까지는 구조 서명이 바뀌면 * **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다. 안 지우면 옛 이벤트를 새 공장에 대고 * 계산하게 되어 이력이 거짓말을 하기 때문이다 — 부스가 둘이던 시절의 사실을 여섯 개짜리 공장에 계산하면 * 그때 없던 부스에서 일이 있었던 것처럼 보인다. * * 그래서 선택지가 둘뿐이었다: **역사를 잃거나, 거짓말을 하거나.** * * 셋째 길이 이 표다. 이벤트가 `structureRev` 로 자기 구조를 달고 다니고, 재생은 구조가 바뀌는 * 지점에서 전환한 뒤 이어서 계산한다(`replaySegments`). 이력은 "그때 그 공장의 사실" 로 계속 읽힌다. * * ── 왜 model 를 통째로 담나 ───────────────────────────────────────────────── * 재생하려면 **그때의 토폴로지 전체**가 필요하다. 차이(diff)만 담으면 리비전 1부터 다시 계산해 쌓아야 * 하고, 그 재계산 자체가 또 하나의 규칙이 되어 어긋난다. 구조는 이벤트에 비해 드물게 바뀌므로 * 스냅샷을 통째로 두는 편이 싸고 정직하다(보드 이력 `BoardHistory` 와 같은 선택). * * ── audit 컬럼을 다 두지 않는 이유 ────────────────────────────────────────── * 리비전은 **한 번 쓰이면 바뀌지 않는다** — 그 시절의 사실이라 고칠 수가 없다. 그래서 updater· * deletedAt 이 없다(TwinEvent 와 같은 규율). 대신 **누가 구조를 바꿨는지**는 남긴다. * * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.) */ @Entity() @Index('ix_twin_structure_0', (e: TwinStructure) => [e.domain, e.instanceId, e.rev], { unique: true }) @ObjectType({ description: 'One structural revision of a twin — the plant as it stood for a stretch of its journal. Events carry the revision they were produced under, so history can be replayed against the structure that was actually in force instead of being deleted when the plant changes.' }) export class TwinStructure { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the structure revision record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinStructure) => e.domain) domainId?: string @Column() @Field({ description: 'Twin runtime instance this structure belongs to.' }) instanceId: string /** * 리비전 번호 — 1부터. 이벤트의 `structureRev` 가 이것을 가리킨다. * * 0 을 안 쓰는 이유: 컬럼이 없던 시절의 행은 `structureRev` 가 비어 있고, 그 행들은 **가장 오래된 * 리비전**에 속한다. 0 을 유효 번호로 쓰면 "모름" 과 "1번 구조" 가 같은 값이 된다. */ @Column({ type: 'int' }) @Field(type => Int, { description: 'Revision number, starting at 1. Events reference it via structureRev; absent on rows written before structure revisions existed, which belong to the oldest revision.' }) rev: number /** * 구조 서명 — 이 리비전이 앞 리비전과 **정말 다른지** 판정한 근거. * * 서명이 같으면 새 리비전을 만들지 않는다. 프로비저닝은 자주 다시 도는데(부팅마다) 그때마다 * 리비전이 하나씩 늘면 이력이 뜻 없는 마디로 잘게 쪼개진다. */ @Column({ length: 64 }) @Field({ description: 'Short hash of the structural signature (locations + equipment). A re-provision with the same signature does not create a new revision — otherwise every reboot would slice the history into meaningless segments.' }) signature: string /* 읽을 때 어휘 세대를 흡수한다 — 옛 리비전의 모델로 지문을 다시 계산할 때 빈 공장으로 읽히면 구조 변경 감지가 죽는다(`engine/model-vocabulary.ts`). */ @Column({ type: 'simple-json', transformer: modelColumnTransformer }) @Field(type => ScalarObject, { description: 'The full TwinModelDef as it stood for this revision. Stored whole rather than as a diff: replay needs the entire topology, and rebuilding it from diffs would be a second rule that can drift.' }) model: any /** 왜 바뀌었나 — 사람이 남기는 한 줄. 없을 수 있다(자동 프로비저닝은 이유를 모른다). */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Why the structure changed — a human note, absent for automatic provisioning.' }) comment?: string @ManyToOne(type => User, { nullable: true }) @Field(type => User, { nullable: true, description: 'Who changed the structure, when known.' }) creator?: User @RelationId((e: TwinStructure) => e.creator) creatorId?: string @CreateDateColumn() @Field({ nullable: true, description: 'When this revision took effect.' }) createdAt?: Date }