import { axisSeenInJournal } from './axis-journal-evidence.js' import { hydratedDate } from '../../engine/read-time.js' import { statusTally } from './status-tally.js' import { TwinEvent } from '../twin-event/twin-event.js' import { Arg, Ctx, Query, Resolver, UseMiddleware } from 'type-graphql' import { ScalarObject, SpeakRefusals, getRepository } from '@things-factory/shell' import { TwinEngine } from '../../engine/index.js' import { modelGapOf } from '../../engine/model-gap.js' import { readBoardEquipment } from '@operato/ops-contract' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinSpace } from '../twin-space/twin-space.js' import { TwinReference } from '../reference/twin-reference.js' import { declarablePropertySpecs } from '../../engine/local-declarations.js' import { operationBasis } from '../../engine/operation-basis.js' import { TwinLocation } from './twin-location.js' import { TwinEquipment } from './twin-equipment.js' import { TwinOperation } from './twin-operation.js' import { standardsForKind, type StandardId } from './standard-coverage.js' import { isa95Coverage } from './isa95-coverage.js' import { iec61850Coverage } from './iec61850-coverage.js' import { epcisCoverage } from './epcis-coverage.js' /** * 이 공정에 선언된 시간을 **겹에서** 든다 — 좁은 겹이 이긴다(트윈 먼저, 없으면 현장). * * 모델에는 ISO 8601 로 구워지지만 화면에는 **사용자가 넣은 수와 단위**를 보인다: 「PT240S」를 보이면 * 자기가 넣은 값인지 알아볼 수 없다. 선언이 없으면 빈 배열이다(빈 값을 지어내지 않는다). */ function declaredDurationProperty(kind: string, instanceLayer?: any[], spaceLayer?: any[]): any[] { const find = (layer?: any[]) => { const d = (layer ?? []).find(x => x?.target === 'operation' && String(x?.id ?? '') === kind) return (d?.properties ?? []).find((p: any) => p?.id === 'operation.duration') } const p = find(instanceLayer) ?? find(spaceLayer) if (!p) return [] return [{ id: 'operation.duration', value: String(p.value ?? ''), ...(p.uom ? { uom: String(p.uom) } : {}), source: 'local' }] } /** 표준 id → 그 표의 함수. 정책은 `standardsForKind` 가, 표는 각 파일이 가진다. */ const COVERAGE: Record any> = { isa95: isa95Coverage, epcis: epcisCoverage, iec61850: iec61850Coverage } import { unwrapState } from '../../engine/warm-start.js' /* 공유 모듈은 빌드 산출물로 참조한다 — 서버·클라이언트가 같은 규칙을 쓰는 자리다(같은 관행: entity-delta). */ import { axisValueOf } from '@things-factory/headless-twin/dist-shared/axis-read.js' /* 커널 런타임 로드 — CJS 번들(엔진과 같은 방식). 축·관계 선언의 SSOT 다. */ const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, axisAppliesTo } = require('@operato/ops-contract') /** * 축 → 그 축이 이미 **행으로 옮겨졌나**. 옮겨진 축은 DB 가 세고, 아직인 축은 문서를 세어야 한다. * * 이 표가 곧 `ADR-0032` 이주의 진척이다 — 여기 없는 축은 아직 `TwinModelDef` 문서 안에 산다. */ const ROW_BACKED: Record = { locations: TwinLocation, equipment: TwinEquipment, operations: TwinOperation } /** `productionSpec.definition.recipes` 같은 경로를 문서에서 따라간다. */ const at = (o: any, path: string) => path.split('.').reduce((a: any, k: string) => (a == null ? undefined : a[k]), o) function countInDocument(model: any, path: string): number { const v = at(model, path) if (Array.isArray(v)) return v.length return v ? 1 : 0 } /* * 트윈 모델 조회 — **이 트윈은 표준 모델의 어디를 채우고 있나**(`plans/twin-inspection.md` §2 ②). * * ── 답의 크기가 개념 수에 비례한다 ───────────────────────────────────────── * 엔티티를 **절대 싣지 않는다.** 실으면 계약이 곧 무너지고, 규모가 오는 날 이 질의가 그대로 * 병목이 된다. 항목은 별도 질의(`twinModelEntities`)가 페이징으로 낸다. * * ── 개념 목록을 이 코드가 갖지 않는다 ─────────────────────────────────────── * 축·관계는 **커널 카탈로그가 선언**한다(`TWIN_AXES`·`TWIN_RELATIONS`). 그래서 야드의 `dock-door` * 나 에너지의 계량기가 늘어도 여기 손댈 것이 없다 — 도메인마다 화면·질의가 하나씩 늘어나던 * 구조를 끊는 것이 이 설계의 요점이다(`ADR-0031`). * * ── 어떻게 셌는지 밝힌다 ─────────────────────────────────────────────────── * 지금은 축 셋만 행이고 나머지는 문서 안에 있다. 그 사실을 `basis` 로 **축마다** 말한다. * 밝히지 않으면 "이 트윈은 3개 축뿐" 으로 읽히고, 이주가 어디까지 왔는지도 아무도 모른다. */ /** `inputs[].material` 처럼 배열을 지나는 선언 경로를 따라간다 — 배열 표기가 관계 선언의 문법이다. */ function valuesAt(obj: any, field: string): any[] { let cur: any[] = [obj] for (const seg of field.split('.')) { const key = seg.endsWith('[]') ? seg.slice(0, -2) : seg const next: any[] = [] for (const c of cur) { if (c == null) continue const v = c[key] if (v == null) continue if (Array.isArray(v)) next.push(...v) else next.push(v) } cur = next } return cur.filter(v => v != null) } const idOf = (o: any): string | null => (typeof o === 'string' ? o : o?.id ?? o?.key ?? o?.gtin ?? null) /** 상태가 빈 축의 이유 — 저널에 그 사실이 있으면 그렇게 말한다. 판단할 수 없으면 예전 그대로. */ async function emptyStateReason(domainId: string, instanceId: string, axis: string): Promise { try { const seen = await axisSeenInJournal(getRepository(TwinEvent), { domain: { id: domainId }, instanceId }, axis) /* 판단할 수 없는 축(`null`)은 예전 답을 그대로 쓴다 — 모르는 것을 새 사실로 만들지 않는다. */ return seen ? 'observed-not-in-state' : 'none-yet' } catch { /* 저널을 못 읽었다고 카드가 없는 말을 하지는 않는다. */ return 'none-yet' } } /** * 되세운 상태를 담아 둔다 — **열쇠에 저널 끝 리비전이 들어 있어** 새 사건이 오면 저절로 낡는다. * * 계산은 저널 전체를 훑는 일이라(리비전 12만에서 2~3초) 모델 화면을 열 때마다 그 값을 낼 수 없다. * 프로세스 안에만 두고 몇 개만 남긴다 — 다시 뜨면 다시 계산한다(정확함이 캐시보다 앞선다). */ const RECALL_CACHE = new Map() const RECALL_CACHE_MAX = 8 @Resolver() @UseMiddleware(SpeakRefusals) export class TwinModelQuery { /** * 이 선이 **얼마나 이어져 있나** — 그리고 **얼마나 끊겨 있나.** * * 선이 "선언되었고 양끝이 존재한다" 만 말하면 지도는 그림일 뿐이다. 관계에서 가장 중요한 정보는 * 대개 **부재**다 — 기준 자리가 비어 있는 설비 열두 대, 어느 라우트에도 안 실린 공정. 그것이 * 조감도에서 보여야 사람이 끊긴 데를 찾아다니지 않는다. * * 셋을 센다: * · `linked` — 값이 있고 그 값이 상대편에 **실제로 있다** * · `dangling` — 값은 있는데 **가리키는 것이 없다**(끊어진 참조 — 가장 나쁜 부류) * · `missing` — 값 자체가 없다(선택 관계면 정상, 필수 관계면 결손) * * 문서 어휘로 푼다 — 관계 선언의 필드 이름이 원본 문서의 어휘이기 때문이다(행은 자기 컬럼 * 이름을 쓴다). 그 사실을 `fillBasis` 로 함께 낸다. */ private edgeFill(model: any, r: any): any { const fromInfo = axisInfo(r.from) const toInfo = axisInfo(r.target.axis) if (!fromInfo || !toInfo) return {} /* * **문서로 셀 수 없는 관계는 세지 않는다.** 선 채움(이어짐·끊어짐·빈 것)은 두 축을 문서에서 * 살펴 센다. 그런데 일정·실적은 문서에 없다(관측 축) — 그 관계를 문서로 세면 언제나 0 이 나오고, * 화면은 그것을 "전부 끊어졌다" 로 읽는다. 셀 수 없으면 **수를 내지 않는 편이 정직하다.** */ if (axisSource(r.from) !== 'document' || axisSource(r.target.axis) !== 'document') return {} const listOf = (info: any) => { const raw = at(model, info.path) return Array.isArray(raw) ? raw : raw ? [raw] : [] } const targets = new Set(listOf(toInfo).map(idOf).filter(Boolean) as string[]) let linked = 0 let dangling = 0 let missing = 0 for (const item of listOf(fromInfo)) { const vals = valuesAt(item, r.field).map(v => (typeof v === 'object' ? idOf(v) : v)).filter(v => v != null).map(String) if (!vals.length) { missing++; continue } /* 한 항목이 여럿을 가리킬 수 있다(`steps[]`). 하나라도 끊겼으면 끊긴 쪽으로 센다 — "대체로 이어져 있다" 는 진단이 아니다. */ if (vals.every(v => targets.has(v))) linked++ else dangling++ } return { linked, dangling, missing, fillBasis: 'document' } } @Query(returns => ScalarObject, { nullable: true, description: 'What this twin reflects and where it fills the standard model: per-concept counts, how each was counted, and the declared relations between concepts. Never carries entities — use twinModelEntities for items.' }) async twinModelSummary(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return null /* * 어느 원본을 반영하나 — 없을 수 있다(템플릿으로 세운 트윈). 그 사실도 사실이다. * * ── **레퍼런스 열쇠는 인스턴스 이름이 아니다** (2026-08-24 실측) ────────────── * 여기서 `source: instanceId` 로 찾고 있었다. 레퍼런스 하나가 인스턴스 하나를 낳던 시절에는 * 두 값이 같았지만, **레퍼런스 하나가 여러 트윈을 먹이는 순간 어긋난다.** * * 인스턴스 operato-chef-v1-pocheon * origin { kind: 'reference', source: 'seunghwa' } ← 이것이 레퍼런스 열쇠다 * 레퍼런스 seunghwa * * 그래서 요약의 `source` 가 **언제나 `null`** 이었다. 레퍼런스로 세운 트윈인데 화면이 그 원본을 * 못 찾았고, 「마지막 동기화」도 흐름별 신선도도 그릴 자리가 아예 없었다 — 그런데 오류는 없었다 * (`ref ? … : null` 이 알리지 않고 갈렸다). * * 인스턴스가 자기 원본을 말하고 있으므로 **그 말을 먼저 듣는다.** 옛 트윈은 두 값이 같으므로 * 뒤의 것으로 여전히 찾힌다 — 가산적이다. */ const refKey = (inst.origin as any)?.source || instanceId const ref = await getRepository(TwinReference) .findOne({ where: { domain: { id: domainId }, source: refKey } }) .catch(() => null) const model = inst.model ?? {} /* * **한 요청에 저널은 한 번만 계산한다.** * * 관측 축(`orders`·`tasks`)은 상태에서 센다. 그 둘이 각자 `recover()` 를 부르고 있었고, 커널이 * 멈춰 있으면 그것은 **저널 전체를 다시 계산하는 일**이다(23,731건짜리 트윈에서 4.6초, 두 번이면 곱절). * 트윈을 전부 멈춰도 이 화면이 5~7초 걸린 이유가 이것이다 — 부하가 아니라 같은 일을 두 번 한 것이다. * * 한 번만 계산해 나눠 쓴다. 값이 같아지는 것은 덤이 아니라 **옳음**이다: 두 번 계산하면 그 사이에 * 들어온 이벤트 때문에 오더와 작업이 서로 다른 시점의 사실이 될 수 있다. */ let statePromise: Promise<{ snap: any; folded: boolean; observing: boolean }> | null = null const stateOnce = () => (statePromise ??= (async () => { const observing = TwinEngine.isRunning(domainId, instanceId) let snap = observing ? unwrapState(TwinEngine.snapshot(domainId, instanceId)) : null let folded = false if (!snap) { try { snap = unwrapState(await TwinEngine.recover(domainId, instanceId)) folded = !!snap } catch { /* 다시 계산할 것이 없다 — 0 으로 메우지 않는다(카드는 "모름" 을 낸다). */ } } return { snap, folded, observing } })()) /* * ── **라이브가 잊은 것을 저널이 안다** (2026-08-23) ───────────────────────── * * 미러의 관측은 메모리에만 산다. 재기동하면 빈 채로 시작하고, 원본이 다시 말해 줄 때 채워진다 — * 그런데 읽기 커서가 정직해진 뒤로는 원본이 **지난 것을 다시 말하지 않는다.** 그래서 재고 741건이 * 저널에 있는데 화면은 「0」을 보였다. 사용자가 「재고현황이 없다」고 물은 자리가 이것이다. * * 그 0 은 사실이 아니라 **우리가 잊은 것**이다. 저널을 계산하면 그대로 되살아난다(실측: 리비전 * 97,689 을 2.4초에 계산해 물품 741 · 작업 9,145 · 지시 2,856). * * ── 그래서 계산한다. 다만 **언제 들은 값인지 함께 낸다** ────────────────────── * 저널을 계산하는 것은 **원천이 우리에게 말한 것을 다시 세우는 일**이지, 없는 값을 심는 일이 아니다. * 그러나 물품·재고처럼 **절대값인 축**은 우리가 못 듣는 사이 떠났을 수 있다 — 그래서 값과 함께 * `heardAt`(그 계산의 시계)을 낸다. 사용자가 신선도를 스스로 판정할 수 있어야 하고, 화면은 그것을 * 값 옆에 적는다. 시각 없이 수만 내면 낡은 값을 지금으로 읽는다. * * 계산하는 조건을 좁게 둔다: **관측 축이 비어 있고, 저널에 그 축의 사실이 있을 때만**(그 판정은 색인 * 한 줄이다). 그리고 한 요청에 **한 번만** 계산한다 — 위 `stateOnce` 와 같은 규율이다. */ let journalPromise: Promise<{ snap: any; heardAt: string | null } | null> | null = null const journalOnce = () => (journalPromise ??= (async () => { try { /* * 관계로 묻지 않는다 — 두 단계 질의가 되어 안쪽에 상한이 없고, 그 트윈의 저널을 전부 만든 뒤 * 한 줄을 고른다(2026-08-28 · 같은 모양이 현장 성과 화면에서 7.2초였다). 색인의 끝만 읽는다. */ const [tipRow, latestRow] = await Promise.all([ getRepository(TwinEvent) .createQueryBuilder('e') .select('MAX(e.revision)', 'max') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .getRawOne<{ max: unknown }>(), /* * 「저널 끝의 사건 시각」은 **가장 늦은 사건 시각**이다 — 리비전이 가장 큰 행의 시각이 * 아니다. 그 둘은 어긋난다(연결된 시스템을 되읽는 트윈은 원본의 시각을 싣고 도착 순서로 * 리비전을 받는다). 색인의 끝을 집으므로 질의 둘이어도 각각 한 줄이다. */ getRepository(TwinEvent) .createQueryBuilder('e') .select('MAX(e.eventTime)', 'at') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .getRawOne<{ at: unknown }>() ]) const tip = { revision: Number(tipRow?.max) || 0, eventTime: hydratedDate(getRepository(TwinEvent), 'eventTime', latestRow?.at) } if (!tip.revision) return null /* * **한 번 계산한 것을 다시 계산하지 않는다.** 계산은 저널 전체를 훑는 일이다(리비전 12만에서 2~3초). * 이 화면은 사람이 여러 번 들어오는 자리이므로 그대로 두면 매번 그 값을 낸다 — 그리고 저널이 * 수백만 행이 되면 그 비용이 화면을 못 쓰게 만든다. * * 열쇠에 **저널 끝의 리비전**을 넣는다: 새 사건이 오면 열쇠가 달라지므로 캐시가 저절로 낡는다 * (캐시가 사실을 이기지 않는다). 프로세스 안에만 두고 몇 개만 남긴다. */ const key = `${domainId}:${instanceId}:${tip.revision}` const hit = RECALL_CACHE.get(key) if (hit) return hit const snap = unwrapState(await TwinEngine.recover(domainId, instanceId, tip.revision)) if (!snap) return null /* 그 계산의 시계가 「언제 들은 값인가」다. 없으면 저널 끝의 사건 시각으로 답한다. */ const out = { snap, heardAt: (snap as any).nowTime ?? tip.eventTime?.toISOString() ?? null } RECALL_CACHE.set(key, out) /* 오래된 열쇠부터 버린다 — 트윈 몇 개를 번갈아 봐도 메모리가 자라지 않게. */ while (RECALL_CACHE.size > RECALL_CACHE_MAX) RECALL_CACHE.delete(RECALL_CACHE.keys().next().value as string) return out } catch { /* 계산하지 못했으면 아무 말도 만들지 않는다 — 라이브가 말한 것을 그대로 둔다. */ return null } })()) /* * 개념 카드가 선언을 그대로 나른다 (2026-09-18, ADR-0074). * * `journalAxis` 는 「이 축의 개체 이력을 저널의 어느 축으로 묻나」이고, 없으면 저널이 그 축으로 * 기록하지 않는다는 뜻이다. `describedBy` 는 식별자 말고 무엇이 그 개체를 말하나이다. 둘 다 앱의 * 화면 상수에 있었고, 축 이름이 바뀌었을 때 따라오지 못해 세 축의 이력이 조용히 비었다. * 여기서 내보내면 화면은 목록을 갖지 않는다 — 축이 늘어도 화면은 그대로다. */ const concepts = await Promise.all( TWIN_AXES.map(async (a: any) => { /* * **이 종류에 없는 개념인가** — 「아직 없음」과 「해당 없음」은 다른 말이다. * * 에너지 트윈에는 옮겨 다니는 물건도, 배정할 사람도, 공정도 없다(커널이 축마다 선언한다). * 그것을 「로트 0」으로 그리면 사용자는 채우러 갈 곳을 찾고, 적합성 표는 채우지 못한 것으로 * 센다 — 둘 다 거짓이다. 수는 `null`(모름이 아니라 셀 대상이 아님)이고 이유를 코드로 낸다. * * 세지도 않는다: 없는 개념을 행에서 세는 것은 질의만 늘리고 답을 바꾸지 않는다. */ if (!axisAppliesTo(a.axis, inst.kind)) { return { axis: a.axis, label: a.label, kind: a.kind, container: false, standardClass: a.standardClass, typeRole: a.typeRole ?? null, journalAxis: a.journalAxis ?? null, describedBy: a.describedBy ?? null, count: null, notApplicable: true, emptyBecause: 'not-applicable', /* * **그 개념의 성질** — 선언인가 관측인가 (2026-08-22). * * `basis` 와 다르다: `basis` 는 「이 값이 어디서 왔나」이고 트윈마다·시점마다 달라진다(같은 * 축이 한 트윈에서 `journal`, 다른 트윈에서 `state` 로 온다). 그것으로 묶으면 같은 축이 화면 * 마다 다른 묶음에 들어간다. `source` 는 트윈과 무관하게 고정이므로 묶는 축이 된다. * * 화면이 축 이름을 열거하지 않아도 되고, 축이 늘면 자동으로 맞는 묶음에 들어간다 — * 카탈로그가 정본이다. */ source: axisSource(a.axis), basis: 'declaration' } } const entity = ROW_BACKED[a.axis] /* 문서 축만 문서로 센다 — 경로 없는 축을 세면 모델 루트를 훑는다(상태 축은 아래에서 센다). */ const inDocument = axisSource(a.axis) === 'document' ? countInDocument(model, a.path) : 0 const inRows = entity ? await getRepository(entity).count({ where: { domain: { id: domainId } as any, instance: { id: inst.id } as any } }) : undefined /* * **둘을 함께 세고 어긋나면 말한다.** * * 행으로 옮긴 축이라도 그 트윈이 투영을 거치지 않았으면 행은 0이다(인제스트 없이 * warm-start 된 트윈이 그렇다). 행만 보고 답하면 문서에 자리 6개가 있는데 화면은 * **0 이라고 말한다** — 들어온 것이 없는 거짓이고, 이 프로젝트가 내내 잡아 온 바로 그 부류다. * * 그래서 어느 하나로 메우지 않는다. 행 수를 답하되 문서 수를 함께 내고, 다르면 * `stale` 로 표시한다 — 그 차이 자체가 "이 트윈은 투영 이후 인제스트되지 않았다" 는 정보다. */ const stale = entity !== undefined && inRows !== inDocument /* * **그릇인가** — 문서에 객체 하나로 있는 축(`productionSpec`). * * `kind` 로는 갈리지 않는다: 커널은 공정·레시피·라우트도 `spec` 이라 부른다(같은 선언 부류라는 * 뜻이다). 그릇인지 아닌지는 **모양**이 말한다 — 목록이 되는 축은 배열이고, 한 벌뿐인 선언은 * 객체 하나다. 개념 지도는 개체를 세는 면이므로 그릇은 자기 면에서 통째로 읽는다. */ const raw = axisSource(a.axis) === 'document' ? at(model, a.path) : undefined /* * **관측 축은 상태가 센다**(일정·실적 — ISA-95 Part 4). 커널이 멈춰 있으면 `null` 이다: * 0 으로 내면 "작업이 없는 공장" 으로 읽히고, 그건 거짓이다(관측이 없는 것과 0 건은 다르다). * 이 화면이 관측 열에서 내내 지켜 온 구분을 카드에서도 지킨다. */ /* * **「아직 없다」와 「왔는데 상태에 없다」는 다른 사실이다** (2026-08-22). * * 화면이 「작업 — 아직 하나도 없습니다」라고 말했는데 저널에는 `task.status` 5,786건이 있었고 * 마지막 상태가 `in-progress` 였다(원인은 커널의 내보내기였고 그 레인에 넘겼다). 카드가 단정한 * 탓에 사용자가 그 차이를 직접 조사해야 했다 — 화면이 아는 것을 말하지 않은 자리다. * * 그래서 상태가 빈 축은 저널을 **한 건만** 물어본다(색인 한 줄, `journal-count` 의 문). */ if (axisSource(a.axis) === 'state') { /* 스냅샷은 `{revision, state}` 봉투다 — 벗기지 않으면 축이 한 겹 안에 있어 언제나 `null` 이 된다(커널은 도는데 카드가 "모름" 으로 보였다). 벗기는 규칙의 집은 `warm-start` 하나다. */ /* * 관측 중인지는 **런타임이 있는지**로 본다 — 캐시된 스냅샷은 관측이 아니다. * * 동작하지 않으면 저널을 계산해 답한다(라이브=권위 · 히스토리=파생). 카드는 그 차이를 `basis` 로 * 말한다: 파생을 권위처럼 내면 다시 계산한 수를 "지금 이만큼" 으로 읽게 된다. */ const { snap, folded, observing } = await stateOnce() /* 축이 사는 자리는 **커널 선언이 말한다**(`path`) — 축 이름으로만 읽으면 중첩된 축이 언제나 「관측 없음」이 된다(에너지의 수요 구간이 그렇다). 규칙은 `axisValueOf` 한 곳. */ let bucket = snap ? axisValueOf(snap, a) ?? null : null let heardAt: string | null = null let fromJournal = false /* * 라이브가 잊은 축은 **저널에서 되세운다**(§`journalOnce`). 그 축의 사실이 저널에 있을 때만 * 계산하고, 값과 함께 「언제 들은 값인가」를 낸다 — 시각 없이 수만 내면 낡은 값을 지금으로 읽는다. */ if (!folded && Array.isArray(bucket) && bucket.length === 0) { const seen = await axisSeenInJournal(getRepository(TwinEvent), { domain: { id: domainId }, instanceId }, a.axis).catch(() => null) if (seen) { const j = await journalOnce() const jb = j?.snap ? axisValueOf(j.snap, a) ?? null : null if (Array.isArray(jb) && jb.length) { bucket = jb heardAt = j?.heardAt ?? null fromJournal = true } } } return { axis: a.axis, label: a.label, kind: a.kind, container: false, standardClass: a.standardClass, typeRole: a.typeRole ?? null, journalAxis: a.journalAxis ?? null, describedBy: a.describedBy ?? null, count: Array.isArray(bucket) ? bucket.length : null, /* * **수 하나가 뜻을 바꾸는 자리** (2026-08-23). * * 미러가 완료된 작업까지 상태에 들고 있게 되면서 「작업 2,820」이 되었다. 이 카드는 상태의 * 인구조사이므로 그 수가 맞다 — 그런데 사람은 그것을 「지금 할 일 2,820건」으로 읽는다. * 수를 줄여 거짓으로 만드는 대신 **무엇으로 이루어진 수인지** 함께 낸다. * * 낱말은 커널의 것을 그대로 쓴다(`created`·`assigned`·`in-progress`·`completed`). 화면이 * 라벨을 입히고, 없으면 낱말이 그대로 보여 누락이 드러난다. */ ...statusTally(bucket), /* * **비었으면 왜 비었는지 말한다.** 0 과 `null` 만 내면 화면은 "없다" 까지만 말하고, 사용자는 * "안 적어 놨나 · 아직 안 일어났나 · 못 세었나" 를 구별할 수 없다. 이 화면이 관측 열에서 * 내내 지켜 온 구분(§observedAbsence)을 카드에서도 지킨다. */ ...(Array.isArray(bucket) ? bucket.length ? {} : { emptyBecause: await emptyStateReason(domainId, instanceId, a.axis) } : { emptyBecause: 'not-observed' }), source: axisSource(a.axis), basis: folded || fromJournal ? 'journal' : 'state', /* * **언제 들은 값인가** — 저널에서 되세운 축만 갖는다. 라이브가 답한 축은 지금이므로 없다. * 이 값이 없으면 화면이 낡은 수를 지금으로 읽는다(그 구별이 이 카드의 전부다). */ ...(fromJournal && heardAt ? { heardAt, recalled: true } : {}), observing, /* 선언이 아니라 관측인 축 — 화면이 "원본이 침묵했다" 로 읽지 않게 사실로 말한다. */ observedOnly: true, standardUndeclared: !a.standardClass?.isa95 && !a.standardClass?.epcis && !a.standardClass?.iso55000 } } return { axis: a.axis, label: a.label, kind: a.kind, container: !Array.isArray(raw) && raw != null, standardClass: a.standardClass, typeRole: a.typeRole ?? null, journalAxis: a.journalAxis ?? null, describedBy: a.describedBy ?? null, count: entity ? inRows : inDocument, /* * 비었으면 왜 비었는지 — 선언 쪽은 세 갈래다. * · `not-declared` 이 트윈의 모델이 그 축을 아예 말하지 않는다(대개 템플릿이 안 적었다) * · `declared-empty` 선언은 있는데 목록이 비어 있다(적었다가 지운 것도 사실이다) * · `not-projected` 문서엔 있는데 행에 없다 — 투영을 거치지 않은 트윈이다(`stale` 과 같은 사실) * * 셋을 "0" 하나로 합치면 사용자가 손볼 곳을 찾지 못한다: 첫째는 템플릿, 둘째는 원본, * 셋째는 다시 읽기다. 셋 다 다른 일이다. */ ...((entity ? inRows : inDocument) ? {} : { emptyBecause: entity && inDocument > 0 ? 'not-projected' : raw == null ? 'not-declared' : 'declared-empty' }), /* 'rows' = DB 가 셌다(질의 가능) · 'document' = 문서를 세었다(아직 이주 전). */ source: axisSource(a.axis), basis: entity ? 'rows' : 'document', ...(entity ? { documentCount: inDocument, stale } : {}), /* 표준 투영이 없는 축은 **숨기지 않고** 그 사실을 낸다(ADR-0031). */ standardUndeclared: !a.standardClass?.isa95 && !a.standardClass?.epcis && !a.standardClass?.iso55000 } }) ) /* * **원본이 모르는데 실행 중인 것** — 커널은 마스터에 없던 자원도 이벤트로 알게 되고 * `origin: 'observed'` 를 단다(`observed-reducer`). 그것을 표에 밀어 넣지 않는다 — 그러면 * 틱 루프에서 DB 로 쓰는 **두 번째 쓰기 경로**가 생기고, 실패하면 알리지 않고 어긋난다 * (쓰기 경로는 인제스트 하나, ADR-0032-D). * * 대신 **여기서 비교해 낸다.** 이 차이는 숨겨야 할 불일치가 아니라 인스펙션이 답해야 할 * 질문이다 — "원본이 말한 적 없는 설비가 실제로 실행 중이다." * * 트윈이 안 실행 중이면 비교할 상대가 없다 — 그때는 `null` 이다(0 이 아니다. 없는 것과 * 세어 보지 못한 것은 다르다). */ /* * 도출은 **한 벌**이다(`engine/model-gap.ts`). 예전에는 이 자리에 인라인으로 있었는데, 같은 사실을 * 「현장과 맞춰지고 있나」 화면도 물어야 해서 두 곳이 다른 수를 말할 위험이 생겼다. * * 이 화면은 **전체 목록**이 필요하다(인스펙션이니까). 그래서 상한 없는 원본을 여기서 만들고, * 상한이 걸린 요약은 `modelGapOf` 가 답한다 — 같은 판정(`origin === 'observed'`)을 쓴다. */ const snap = TwinEngine.snapshot(domainId, instanceId) const gap = modelGapOf(snap) const observedOnly = snap ? { equipment: (snap.equipment ?? []).filter((e: any) => e?.origin === 'observed').map((e: any) => e.id), locations: (snap.locations ?? []).filter((l: any) => l?.origin === 'observed').map((l: any) => l.id), persons: (snap.persons ?? []).filter((p: any) => p?.origin === 'observed').map((p: any) => p.id), /* 커널이 담을 줄 몰라 세어 둔 사건·모르는 설비 보고 — 인스펙션이 이미 물을 자리다. */ unhandled: gap?.unhandled ?? [], unknownEquipmentReports: gap?.unknownEquipmentReports ?? null, notCovered: gap?.notCovered ?? [] } : null /* 지도의 선 — 선언된 관계 중 **양끝이 이 트윈에 실재하는 것만**. 없는 선을 그리지 않는다. */ /* 선의 양끝 판정은 **문서든 행이든 있으면 있는 것**으로 본다 — 투영이 낡았다고 관계가 없어지진 않는다. */ const nonEmpty = new Set(concepts.filter(c => (c.count ?? 0) > 0 || ((c as any).documentCount ?? 0) > 0).map(c => c.axis)) /* 선은 **얼마나 이어졌고 얼마나 끊겼는지**까지 말한다 — 관계에서 가장 중요한 정보는 대개 부재다. */ const edges = await Promise.all( TWIN_RELATIONS.filter((r: any) => r.target.kind === 'axis' && nonEmpty.has(r.from) && nonEmpty.has(r.target.axis)).map( async (r: any) => ({ from: r.from, to: r.target.axis, via: r.via, optional: !!r.optional, ...this.edgeFill(model, r) }) ) ) return { instanceId, /* 사람이 부르는 이름 — 결선 줄이 업무키만 보이지 않게(업무키는 곁에 남는다). */ name: inst.name ?? null, /* * **생산 선언에서 갈 곳이 없는 두 값** — GS1 발행자와 대표 산출물. * * 나머지 선언(자재·레시피·라우트·공정)은 각자 개념 축이 있고, 자재의 GS1 참조는 자재가 보인다. * 이 둘만 자기 축이 없다: 회사 접두사는 이 트윈의 **정체**(발행자)이고, 대표 레시피는 이 공장이 * 무엇을 만드는 곳인가다. 둘 다 결선 줄(어느 원본을 반영하나)과 같은 성격이라 그 줄에 둔다. */ /* * **표준 적합성** — 채운 것과 빠진 것을 함께. * * 이 화면은 "이 트윈이 표준 모델의 어디를 채우고 있나" 를 묻는다. 그런데 지금까지 답은 우리 * 축만 세었다 — 없는 것은 화면에 없으므로 아무도 묻지 않았다. 기준(표준의 목록)을 함께 내면 * 그 질문이 실제로 답을 갖는다(`isa95-coverage.ts` 의 머리말 참조). */ /* 종류가 기대는 표준을 **모두** 낸다 — 하나만 말하면 그 트윈의 다른 기준이 보이지 않는다. */ standards: standardsForKind(inst.kind).map(id => COVERAGE[id]((TWIN_AXES as any[]).map(a => a.axis))), production: (() => { const spec: any = (inst.model as any)?.productionSpec if (!spec) return null const key = spec.recipeKey ?? null const material = (spec.definition?.materials ?? []).find((m: any) => (m.key ?? m.id) === key) return { companyPrefix: spec.companyPrefix ?? null, recipeKey: key, /* 이름은 자재 선언이 말한다(i18n 키일 수 있으므로 번역은 화면의 일). */ recipeLabel: material?.label ?? material?.name ?? null } })(), /* * ── **식별자가 무엇에 근거하나** (2026-08-24) ────────────────────────────── * 요약이 `production.companyPrefix` 를 그대로 보이는데 **그것이 발급받은 번호인지 말할 근거가 * 없었다.** 그래서 사용자가 긴 URI 를 보고 「GS1 을 강제하는 것인가」를 물었다 — 강제는 **이미 * 없고**(GS1 프리픽스가 없는 현장은 자기 도메인 아래에서 식별자를 만든다, CBV 2.0 §8.3.4) * 없다는 사실이 화면에 없었던 것이 문제였다. * * ★ **이 축만은 무표를 쓰지 않는다.** GS1 형태의 식별자를 본 사람은 그것이 진짜 발급받은 * 번호라고 가정하므로, 표식이 없으면 그 가정이 곧 거짓 안심이 된다. 계약도 그렇게 적고 있다 — * 「없으면 소비처는 판정하지 않는다. 기본값으로 `issued` 를 가정하면 그 화면이 곧 거짓이 된다.」 * 그래서 화면이 네 갈래 모두에 낱말을 붙일 수 있게 **그대로** 낸다 * (`claimed-issued`·`declared-namespace`·`gs1-shaped`·`kernel-constant`). * * 커널이 내는 것을 그대로 넘긴다 — 여기서 다시 판정하면 스냅샷과 요약이 다른 말을 한다. * 트윈이 돌지 않거나 커널이 말하지 않으면 `null` 이고, 그때 화면은 **재지 않는다.** */ identityGrounding: (await stateOnce()).snap?.identityGrounding ?? null, description: inst.description ?? null, kind: inst.kind, restartPolicy: inst.restartPolicy, source: ref ? { referenceId: ref.source, adapterType: ref.adapterType, status: ref.status, lastSyncedAt: ref.lastSyncedAt, /* * ── **원본의 어느 흐름이 어디까지 살아 있나** (2026-08-24) ────────────── * 사용자가 「지금 데이터가 들어오는 것이 사실이냐」고 물었고, 화면이 답할 것이 「붙어 있다」 * 뿐이었다. 그런데 한 원본 안에서 흐름마다 신선도가 **크게 다르다**(실측: 재고 08-23 · * 계측 08-12 · 작업지시 05-10 · 공정·로트 04-15). 「붙어 있다」 하나로 뭉치면 넉 달 멈춘 * 흐름이 살아 있는 것으로 읽힌다. * * **계약에는 자리가 있다** — `LiveFeedCursor.since` 가 「그 흐름에서 마지막으로 본 시각 * (원본의 시각)」이고 `onCursor` 가 그것을 `TwinReference.liveCursor` 에 적는다. 없던 것은 * **화면으로 가는 문**이고, 그 문이 여기다. * * ★ **다만 지금은 비어 있다** — 실 저장값을 확인했다: * `{"streams":{},"firstAttachedAt":"2026-03-25T…"}`. 즉 어댑터가 흐름별 커서를 적지 않는다. * 그래서 이 문을 열어도 당장은 빈 목록이 나오고, **그것이 정직한 답이다**: 어댑터가 말하지 * 않는 것을 여기서 지어내지 않는다. 채우는 것은 어댑터의 몫이고 그쪽에 알렸다. * * 빈 목록과 「흐름이 없다」를 화면이 구별할 수 있어야 한다 — 이 값이 비면 그것은 * 「어댑터가 아직 말하지 않는다」이고 「원본에 흐름이 없다」가 아니다. * * `seen`(중복 걸러낸 행 id)은 내지 않는다 — 읽기 내부 상태이고 수천 개가 될 수 있다. * 흐름 열쇠는 **어댑터가 정한다**(계약이 이름을 닫지 않는다). 그래서 이름을 번역하지 않고 * 그대로 낸다 — 화면이 모르는 이름을 만나면 그 이름을 보이는 것이 맞다. */ streams: Object.entries((ref.liveCursor as any)?.streams ?? {}) .map(([stream, c]: [string, any]) => ({ stream, since: c?.since ?? null })) /* 시각을 말하지 않는 흐름도 낸다 — 「모른다」와 「없다」는 다르고, 빼면 그 흐름이 사라진다. */ .sort((a, b) => String(b.since ?? '').localeCompare(String(a.since ?? ''))), firstAttachedAt: (ref.liveCursor as any)?.firstAttachedAt ?? null } : null, /* * **다시 읽을 수 있는가** — 이 트윈이 자기 원본을 기억하고 있나(`TwinInstance.origin`). * * 없는 트윈이 실제로 있다(이 자리가 생기기 전에 만들어진 것). 그때 화면이 버튼을 그대로 * 내면 누를 때마다 실패하고, 사용자는 무엇이 잘못됐는지 알 수 없다. 그래서 **할 수 있는지를 * 먼저 답한다** — 못 하는 것을 못 한다고 말하는 것도 이 화면의 일이다. */ origin: inst.origin ?? null, canResync: !!(inst.origin as any)?.kind, concepts, edges, /* 원본이 모르는데 관측에는 있는 것. `null` = 트윈이 실행 중이 아니어서 비교하지 못했다. */ observedOnly, /* * 축 셋만 행이고 나머지는 문서에서 셌다는 사실. 축마다 `basis` 가 있지만 여기서도 한 번 말한다 — * 화면이 "이 숫자는 무엇으로 셌나" 를 한눈에 보여야 규모 한계가 드러난다(ADR-0032 이주 압력). */ basis: { rows: Object.keys(ROW_BACKED), document: TWIN_AXES.filter((a: any) => !ROW_BACKED[a.axis]).map((a: any) => a.axis), /* 투영이 낡은 축 — 있으면 이 트윈은 인제스트를 한 번 더 태워야 한다. */ stale: concepts.filter((c: any) => c.stale).map((c: any) => c.axis) } } } /** * **이 트윈에 무엇을 선언할 수 있나** — 값을 넣는 화면이 도메인의 모양을 알지 않게. * * 커널이 정한 명세(속성 id·단위·범위·설명)를 그대로 낸다. 화면이 「에너지 트윈이면 계약전력·요금 * 단가…」를 알고 있으면 도메인이 늘 때마다 화면을 고쳐야 하고 단위가 두 곳에 적힌다(ADR-0031). * * 지금 무엇이 선언돼 있는지도 함께 낸다(`declared`) — 화면이 두 번 묻지 않게, 그리고 「원천이 말한 * 값」과 「우리가 정한 값」을 한자리에서 견줄 수 있게(`PropertySource`, ADR-0034). * * 어휘가 없는 종류는 `properties: []` 다 — 「아직 없다」와 「아무 값이나 받는다」는 다르다. */ @Query(returns => ScalarObject, { nullable: true, description: "What can be declared on this twin and what already is: the kernel's property specification (id, unit, range, note) plus the current local declarations at both scopes — this twin and its facility — each with the value it overrode. Empty properties mean this kind has no declaration vocabulary yet — not that anything goes." }) async twinDeclarations(@Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return null const model = (inst.model ?? {}) as any /* * 겹이 둘이다 — **현장의 것과 이 트윈의 것**. 화면이 둘을 함께 봐야 「이 값이 어느 범위의 것인가」를 * 말할 수 있고, 철회 버튼이 어느 겹을 겨눌지 고를 수 있다(요금을 현장에 넣었는데 트윈에서 철회하려 * 하면 「겹에 없다」로 끝난다 — 사용자는 이유를 알 수 없다). */ const space = inst.spaceId ? await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: inst.spaceId } }) : null /* * 돌고 있으면 커널이 「무엇으로 돌고 있나」를 스스로 답한다. 그 보고는 **작업이 새로 생길 때** 채워지 * 므로 재기동 직후에는 비는데, 이력에서 배운 종류는 그때도 남아 있다(실측으로 이 빈칸에 부딪혔다). */ const operations = operationBasis( model, TwinEngine.kernel(domainId, instanceId), await TwinEngine.measuredOperationKinds(domainId, instanceId) ) return { instanceId, kind: inst.kind, spaceId: inst.spaceId, spaceName: space?.name, properties: declarablePropertySpecs(inst.kind), declared: (inst.localDeclarations ?? []) as any[], declaredAtSpace: (space?.localDeclarations ?? []) as any[], /* 어디에 얹을 수 있나 — 모델의 자리·설비·공정. 화면이 대상을 지어내지 않게 목록으로 준다. */ targets: [ ...(model.locations ?? []).map((l: any) => ({ target: 'location', id: l.id, name: l.name, type: l.type, properties: l.properties ?? [] })), ...(readBoardEquipment(model) ?? []).map((e: any) => ({ target: 'equipment', id: e.id, name: e.name, type: e.type ?? e.kind, properties: e.properties ?? [] })), /* * 공정은 **자원이 아니다** — 값은 종류에 붙고, 그 종류에 명세 행이 없어도 된다(창고·야드에는 행이 * 0개다). 그래서 대상 목록을 커널의 자기보고까지 합쳐 만든다(`operationBasis`). * * 지금 값은 **겹에서** 든다(사용자가 넣은 수와 단위 그대로). 모델에는 ISO 8601 로 구워지지만 * 화면에 「PT240S」를 보이면 자기가 넣은 값인지 알아볼 수 없다. */ ...operations.map(op => ({ target: 'operation', id: op.kind, name: op.label, properties: declaredDurationProperty(op.kind, inst.localDeclarations as any[], space?.localDeclarations as any[]) })) ], /* * 이 트윈이 돌리는 공정과 **그 시간이 어디서 오나** — 선언 화면이 「무엇이 상수로 도나」를 보여야 * 사람이 채울 곳을 안다. `basis` 가 비면 아직 돌지 않은 것이다(공정이 없는 것이 아니다). */ operations } } /** * **이 현장에 무엇을 선언할 수 있나** — 현장 값은 트윈의 것이 아니므로 트윈 없이 묻는다. * * ── 왜 트윈으로 묻지 않나 (2026-08-18) ───────────────────────────────────── * 처음에는 트윈 화면에서 범위만 골라 현장 값을 넣게 했다. 그런데 **현장 값을 넣으려고 트윈을 골라야 * 하는 것**은 개념이 거꾸로다 — 트윈은 그 값을 읽는 쪽이다(사용자가 그 어긋남을 지적했다). * * 어휘와 대상은 **그 현장 트윈들의 합집합**이다: 한 현장에 여러 종류가 살고(에너지·창고·야드), 요금은 * 에너지 트윈만 읽는다. 합집합으로 내되 **어느 트윈이 그 대상을 가졌는지** 함께 낸다 — 아무 트윈도 * 읽지 않는 값을 넣는 일을 화면이 미리 보여 줄 수 있어야 한다. */ @Query(returns => ScalarObject, { nullable: true, description: 'What can be declared for a whole facility (space) and what already is: the union of the property specifications of the twin kinds living there, the union of their declarable targets (with which twins carry each), and the current facility-scope declarations.' }) async spaceDeclarations(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) if (!space) return null const twins = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } }) /* 어휘 합집합 — 같은 속성이 여러 종류에 있어도 한 번만 낸다(명세는 커널 하나에서 온다). */ const byId = new Map() for (const t of twins) for (const spec of declarablePropertySpecs(t.kind)) if (!byId.has(spec.id)) byId.set(spec.id, spec) /* 대상 합집합 — 같은 id 가 여러 트윈에 있으면 **그 트윈들을 함께** 든다(현장 값은 그 전부에 얹힌다). */ const targets = new Map() for (const t of twins) { const model = (t.model ?? {}) as any const add = (target: 'location' | 'equipment' | 'operation', e: any) => { const key = `${target}:${e.id}` const cur = targets.get(key) if (cur) { cur.twins.push(t.instanceId); return } targets.set(key, { target, id: e.id, name: e.name, type: e.type ?? e.kind, twins: [t.instanceId] }) } for (const l of model.locations ?? []) add('location', l) for (const e of readBoardEquipment(model) ?? []) add('equipment', e) /* * 공정도 대상이다 — 「우리 현장의 적재는 4분」은 그 현장 창고 전부가 읽어야 하는 수다. 목록은 각 * 트윈의 것을 합집합으로 든다(돌고 있으면 커널이 스스로 답한다). */ for (const op of operationBasis( model, TwinEngine.kernel(domainId, t.instanceId), await TwinEngine.measuredOperationKinds(domainId, t.instanceId) )) add('operation', { id: op.kind, name: op.label }) } return { spaceId, spaceName: space.name, properties: [...byId.values()], declared: (space.localDeclarations ?? []) as any[], targets: [...targets.values()], /* 이 현장에 사는 트윈들 — 화면이 「누가 이 값을 읽나」를 말할 수 있게. */ twins: twins.map(t => ({ instanceId: t.instanceId, kind: t.kind, name: t.name })) } } }