import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' import { ObjectType, Field, ID, Int } from 'type-graphql' import type { RefusalCode, RefusalParams } from '@operato/ops-contract' import { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell' /** 조치 검증 상태 — 「아직 안 해 봤다」는 실패가 아니라 그 자체로 하나의 사실이다. */ export type ActuationState = 'unverified' | 'verified' | 'failed' /** 마지막 거절 — 언어 중립 코드와 그 값들. 사람 문장은 담지 않는다. */ export interface ActuationFailure { code: RefusalCode /** 거절된 시각 (ISO 8601). */ at: string params?: RefusalParams } /* * TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018). * 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티. * inbound 전용(구조·상태). 아웃바운드 액추에이션은 command-routing(ActuationAdapter) 소유. * mappingSpec/scopeSpec 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통). * connectionConfig 는 자격 증명을 담으므로 암호화되는 텍스트 칸이다(칸 주석 참조). * 설계 SoT: operato-twin/design/plans/reference-management.md. * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.) */ @Entity() @Index('ix_twin_reference_0', (e: TwinReference) => [e.domain, e.source], { unique: true }) @ObjectType({ description: 'A tenant-scoped connection to an external source system — the origin for ingesting twin instances (Face2).' }) export class TwinReference { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the reference record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinReference) => e.domain) domainId?: string @Column() @Field({ description: 'Reference source id (unique within a domain).' }) source: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Domain kernel system: wms | yms | mes.' }) system?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Human-readable site/system name.' }) siteName?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Optional description.' }) description?: string @Column() @Field({ description: "Adapter type registry key (e.g. 'virtual' | 'rest' | 'db' | 'epcis')." }) adapterType: string /** * 이 연결에 필요한 값 — **자격 증명이 여기 들어온다. 그래서 저장될 때 암호화된다.** * * ── 왜 표에 두나 (2026-09-09) ───────────────────────────────────────────── * 비밀값은 환경에 두고 이름으로 고르는 것이 원칙이다(§`webhookSecretCandidates`). 그 원칙은 * **설치본마다 하나인 키**에 맞는다 — 배포할 때 넣으면 된다. * * 트윈의 연결은 그 모양이 아니다. **사람이 화면에서 연결을 만든다.** 새 공장을 붙이려고 재배포할 * 수는 없으므로, 연결마다 다른 이 값은 표에 있어야 한다. 그 자리에서 원칙이 갈린다: * * 설치본마다 하나인 키 환경변수. 이름이 어느 상대 것인지 말한다 * 연결마다 다른 값 표. 대신 저장될 때 암호화한다 * * ── 무엇을 지키고 무엇을 못 지키나 ──────────────────────────────────────── * 저장된 것을 지킨다 — DB 파일 · 백업 · 복제본. 2026-09-09 에 이 칸에서 `hookSecret` 과 접속 * 토큰을 명령 두 개로 읽었다. * * **프로세스를 돌릴 수 있는 사람에게서는 못 지킨다** — 그 사람은 키를 갖고 있다. 그리고 값이 * 화면으로 나가는 것도 이 칸이 막지 않는다: 내보내는 쪽이 어댑터 스키마의 `secret: true` 를 보고 * 가린다(§`reference-resolver` 의 상세 조회). **두 가지가 갈려 있으므로 한쪽만 하면 반쪽이다.** * * 기존 평문 행은 안 깨진다 — 변환기가 암호문 모양이 아닌 값을 알아보고 파싱하며 경고를 남기고, * 다시 저장될 때 암호화된다. */ @Column({ nullable: true, ...encryptedJsonColumn() }) @Field(type => ScalarObject, { nullable: true, description: 'Adapter-specific connection config (endpoint, credentials, params). Encrypted at rest; secret-declared fields are masked on the way out.' }) connectionConfig?: any @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Declarative legacy→canonical mapping rules (kernel face2-adapter AdapterRule[]).' }) mappingSpec?: any @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: '1:N fan-out scope (discovered sites). Multiple sites → N instances.' }) scopeSpec?: any @Column({ nullable: true }) @Field({ nullable: true, description: 'Lifecycle status: draft | connected | error.' }) status?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Last successful master sync time (ISO 8601 string; portable across DB drivers).' }) lastSyncedAt?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Last connection/sync error message, if any.' }) lastError?: string /** * **조치를 이 주소로 실제로 넘겨 봤나** (ADR-0046 ②). * * ── 왜 세 값인가 ─────────────────────────────────────────────────────────── * 설정이 맞는 것과 저쪽이 받는 것은 다르다. 주소도 비밀값도 다 적혀 있는데 저쪽 프로세스가 그 값을 * 잃은 일이 실제로 있었다(§`actuationReadiness`). 그것은 **한 번 넘겨 봐야** 알 수 있고, 그래서 * 「아직 안 해 봤다」와 「해 봤고 됐다」와 「해 봤고 안 됐다」가 다른 사실이다. * * unverified 아직 왕복이 없다 — 실패가 아니다. 새 연결과 설정이 바뀐 연결이 여기 선다 * verified `actuate` 가 받아들여졌다. 그 시각이 `actuationVerifiedAt` * failed 거절됐다. 사유는 `actuationFailure` 에 **코드로** * * `unverified` 를 `failed` 로 접지 않는다 — 안 해 본 것을 실패로 그리면 사람이 고칠 것을 찾으러 * 가고, 고칠 것이 없다. */ @Column({ nullable: true }) @Field({ nullable: true, description: "Whether an actuation has actually reached this peer: 'unverified' | 'verified' | 'failed'. Null means the same as unverified; a connection that has never been tried is not a failed one." }) actuationState?: ActuationState @Column({ nullable: true }) @Field({ nullable: true, description: 'When an actuation was last accepted by the peer (ISO 8601 string; portable across DB drivers). Null while unverified.' }) actuationVerifiedAt?: string /** * 마지막 거절 — **코드와 값만 저장한다. 문장은 저장하지 않는다.** * * `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 그러면 번역이 안 되고, 무엇이 * 몇 번 났는지 세지도 못하고, 커넥터가 문장을 고치는 날 옛 행과 새 행이 서로 다른 말을 한다. * 코드는 `CommandAck.errorCode` 와 같은 낱말이다(ADR-0042 ③) — 표현층이 문장을 만든다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Why the last actuation was refused: { code, at, params? }. Language-neutral codes only — the presentation layer builds the sentence. Null when the last attempt was accepted or none was made.' }) actuationFailure?: ActuationFailure /** * 마지막 동기에서 **못 옮긴 값의 수**. `null` = 세지 않았다(0 과 다르다). * * 세지 않은 구조 인제스트는 도크를 랙으로 바꿔 놓고도 성공이라 답하면서 아무 일도 하지 않는다. 그 사실을 사후에 물을 수 * 있어야 단계 판정이 「구조는 됐다」를 말할 수 있다 — 없는 동안 판정은 늘 `unmeasured` 였다. * 기본값을 두지 않는다: 0 을 기본으로 깔면 「경고 없었다」와 「세지 않았다」가 같아진다. */ @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'Number of values the last sync could not carry over (mapping fallbacks). Null means they were not counted at all, which is different from zero.' }) lastWarningCount?: number /** * **어디까지 읽었나** — 라이브 피드의 읽기 커서(§`LiveFeedContinuity`). * * ── 왜 저장하나 (2026-08-23 실측) ────────────────────────────────────────── * 어댑터가 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들고 있었다. 그래서 **재기동마다 * 미러의 과거가 잘렸다** — 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는 것이 「원본의 사실」이 * 아니라 「창의 함수」였고, 그 사실을 아무도 말하지 않았다. * * 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`): 「원천이 애초에 다시 말해 주지 않는 축」은 * 재기동 연속성으로 이어받는다. 「우리가 어디까지 읽었나」가 정확히 그 성질이므로 **어댑터의 사물함이 * 아니라 이 층**에 있다 — 원본이 늘 때마다 저장 기제가 늘고 그중 하나가 알리지 않고 다르게 동작하지 않게. * * ── 왜 캐시가 아니라 표인가 ──────────────────────────────────────────────── * 만료되면 창이 다시 미끄러지고, 그 손실은 오류 없이 들어온 것이 없다. 스냅샷 체크포인트와 성질이 다르다 * (그쪽은 잃어도 원천이 정정해 준다 — 이 값은 **잃으면 원천에 묻지 않게 된다**). * * 모양은 `{ streams: { [흐름]: { since?, seen[] } }, firstAttachedAt? }` 다. **흐름 이름은 어댑터가 * 정한다** — 원본마다 흐름 수와 뜻이 다르므로 이 층은 열쇠로만 다룬다. `simple-json` 이라 드라이버 * 다섯을 그대로 지난다. * * `firstAttachedAt` 은 「언제부터 아는가」다. 커널은 상한만 안다(`nowTime` = 마지막으로 들은 시각). * 둘이 함께 「이 트윈이 아는 구간」이고, 화면이 수를 보일 때 그 구간을 말해야 한다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Live feed read cursor carried across restarts, keyed by adapter-defined stream. Null means the feed has never attached, and the next attach decides its first window.' }) liveCursor?: any @CreateDateColumn() @Field({ nullable: true, description: 'Timestamp when the reference was first created.' }) createdAt?: Date @UpdateDateColumn() @Field({ nullable: true, description: 'Timestamp when the reference row was last updated.' }) updatedAt?: Date }