import type { ReadBasis } from '@operato/ops-contract' import { twinWarn } from '../../engine/log.js' import { Arg, Ctx, Int, Query, Resolver, UseMiddleware } from 'type-graphql' import { ScalarObject, SpeakRefusals, getRepository } from '@things-factory/shell' import { itemRefOf } from './item-ref.js' import { TwinEngine } from '../../engine/index.js' import { unwrapState } from '../../engine/warm-start.js' import { propertyEffectsOf } from '../../engine/property-effects.js' /* 축이 사는 자리를 읽는 **한 곳** — 카드(`twin-model-query`)와 같은 규칙을 쓴다. */ import { axisValueOf } from '@things-factory/headless-twin/dist-shared/axis-read.js' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinLocation } from './twin-location.js' import { TwinEquipment } from './twin-equipment.js' import { TwinOperation } from './twin-operation.js' import { TwinArea } from '../twin-space/twin-area.js' import { at, idOf, nameIndex, namesFor } from './name-index.js' /* 커널 런타임 로드 — 축·관계·속성 선언의 SSOT. 이 파일은 도메인 모양을 알지 않는다. */ const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, documentPath, isCacheableTense, relationsFrom, relationsTo, propertiesOf, splitRelationIds, tenseOf } = require('@operato/ops-contract') /** * 목록의 답 — **근거는 계약이 닫아 둔 다섯 중 하나다**(`READ_BASIS`). * * 2026-09-18 판정(ADR-0075 결정 3 을 코드에 대어 본 결과): 이 문이 `'rows-scanned'` 를 내고 있었다. * 그것은 출처가 아니라 **우리가 어떻게 셌나**이고, 목록 소비자가 근거로 갈래를 나눌 때 계약에 없는 * 값을 만나게 된다. 출처는 `rows` 이고, 훑어 걸렀다는 사실은 `scanned` · `truncated` 가 말한다. * 여기에 칸을 묶어 두면 계약 밖의 값이 다시 생길 때 타입 검사가 잡는다. */ type AxisListAnswer = { axis: string label: string basis: ReadBasis total: number | null offset: number /** 훑어 센 경우에만 — 몇 줄을 읽었나, 상한에서 잘렸나. */ scanned?: number truncated?: boolean [key: string]: unknown } import { externalIncoming, resolveExternal } from './external-resolver.js' /** * 축 → 그 축이 이미 **행으로 옮겨졌나**, 그리고 행의 어느 컬럼이 id 인가. * * 옮겨진 축은 **DB 가 페이징하고 센다.** 아직인 축은 문서 배열을 읽어야 하고, 그 차이를 응답이 * `basis` 로 밝힌다 — 밝히지 않으면 "이 축은 검색이 되는데 저 축은 왜 느린가" 를 아무도 설명할 수 없다. */ const ROW_BACKED: Record = { locations: { entity: TwinLocation, idColumn: 'locationId', order: { locationId: 'ASC' } }, equipment: { entity: TwinEquipment, idColumn: 'equipmentId', order: { equipmentId: 'ASC' } }, operations: { entity: TwinOperation, idColumn: 'operationKey', order: { seq: 'ASC' } } } /* * `productionSpec.binding` 의 키가 사는 축 — 자재가 둘로 갈려 있다. * * materials productionSpec.definition.materials 레시피의 투입 자재 * materialDefinitions materialDefinitions 마스터 품목 목록 * * 예전에는 `materials` 하나만 봤다. 커넥터에 따라 `binding` 키가 어느 축의 id 인지 달라서, MES 연결 * 둘은 품목이 자기 GS1 참조를 한 건도 답하지 못했다. 오류는 안 나고 칸이 비어서 나온다. * * 실측(2026-09-06, 인티그레이션 레인이 찾음): * * 트윈 binding def.materials 맞음 materialDefinitions 맞음 * mes-miratek 106 0 106 * mes-line-a 21 0 21 * operato-chef-v1-pocheon 2789 1789 0 * rosarito-mes 20 20 0 * * **두 축이 동시에 맞는 트윈은 없다**(양쪽 id 에 다 있으면서 binding 키인 것 0개). 그래서 두 축에 * 다 걸어도 어느 쪽을 붙일지 모호해지지 않는다. 조회가 판정한다 — `binding[id]` 는 키가 아닌 축에서 * 그냥 없다. * * 두 축을 한 이름으로 합치지 않는다. 레시피 투입 자재와 마스터 품목은 서로 다른 것이고, 합치면 * 둘 다 있는 트윈(chef)에서 어느 쪽 목록인지 알 수 없게 된다. */ const BINDING_AXES = new Set(['materials', 'materialDefinitions']) /** 검색 — 항목의 값 어디든 걸리면 맞는 것으로 본다. 축마다 검색 컬럼을 정해 두면 축이 늘 때마다 여기를 고쳐야 한다. */ function matches(o: any, q: string): boolean { if (!q) return true const needle = q.toLowerCase() const walk = (v: any, depth: number): boolean => { if (v == null || depth > 3) return false if (typeof v === 'string' || typeof v === 'number') return String(v).toLowerCase().includes(needle) if (Array.isArray(v)) return v.some(x => walk(x, depth + 1)) if (typeof v === 'object') return Object.values(v).some(x => walk(x, depth + 1)) return false } return walk(o, 0) } /** * 항목 조회 — **개념 안의 목록**(③)과 **항목 하나의 상세**(④). * * ── 이 파일이 도메인을 알지 않는다 ───────────────────────────────────────────── * 어느 축이 있고 무엇을 가리키는지는 **커널 카탈로그가 선언**한다(`TWIN_AXES`·`TWIN_RELATIONS`). * 그래서 야드의 도크도어나 에너지의 계량기가 늘어도 여기 손댈 것이 없다(ADR-0031). 이 파일에 * 도메인 이름이 한 번도 나오지 않는 것이 그 증거다. * * ── 알리지 않고 자르지 않는다 ────────────────────────────────────────────────────── * 목록은 항상 **총 건수**와 함께 온다. 그리고 어떻게 셌는지(`basis`)를 밝힌다 — 행이면 DB 가 * 페이징했고, 문서면 서버가 문서를 통째로 읽어 잘라 냈다. 후자는 규모에서 한계가 되므로 그 사실이 * 화면에 드러나야 이주 압력이 살아 있다. */ @Resolver() @UseMiddleware(SpeakRefusals) export class TwinModelItemQuery { @Query(returns => ScalarObject, { nullable: true, description: 'Page through the items of one standard concept in a twin. Always returns the total and how it was counted (rows = the database paged it, document = the server read the whole document and sliced).' }) async twinModelEntities( @Arg('instanceId') instanceId: string, @Arg('axis') axis: string, @Ctx() context: ResolverContext, @Arg('search', { nullable: true }) search?: string, @Arg('offset', type => Int, { nullable: true }) offset?: number, @Arg('limit', type => Int, { nullable: true }) limit?: number, /** * 어느 시각의 사실인가 — **관측 축에만 뜻이 있다.** * * 선언 축은 시간축이 없다(그 시점의 선언은 구조 리비전이 답한다). 그래서 문서 축에 이 값을 주면 * 쓰지 않는다 — 무시했다는 사실은 응답의 `basis` 가 말한다(`document`). */ @Arg('untilTime', { nullable: true }) untilTime?: string ): Promise { const domainId = context.state.domain.id const info = axisInfo(axis) /* 모르는 축은 **거절한다.** 빈 목록으로 답하면 "이 개념은 비어 있다" 로 읽히고, 오타 하나가 "우리 트윈에는 그게 없다" 는 결론으로 굳는다. */ if (!info) throw new Error(`unknown concept axis: "${axis}" — the kernel catalog does not declare it`) const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return null const take = Math.min(Math.max(limit ?? 50, 1), 200) const skip = Math.max(offset ?? 0, 0) const q = (search ?? '').trim() /* 시각 커서는 시간축을 가진 축만 쓴다(`historical`) — 선언 축에서는 뜻이 없다. */ const at_ = axisInfo(axis)?.historical ? untilTime || null : null const row = ROW_BACKED[axis] if (row && !q) { /* 검색어가 없으면 **DB 가 페이징한다** — 이것이 규모에서 유일하게 성립하는 길이다. */ const repo = getRepository(row.entity) const where = { domain: { id: domainId } as any, instance: { id: inst.id } as any } const [rows, total] = await repo.findAndCount({ where, order: row.order, skip, take }) const answer: AxisListAnswer = { axis, label: info.label, basis: 'rows', total, offset: skip, /* 목록도 등급 이름을 쓴다 — 목록 칸에 `assembler-op` 이 그대로 서면 목록이 제 일을 못 한다. */ resourceClassLabels: this.classLabels(inst.model ?? {}), items: rows.map((r: any) => this.rowItem(axis, r)) } return answer } if (row && q) { /* * 검색은 아직 DB 로 내리지 않았다. **그 사실을 밝힌다** — 흉내로 덮으면 규모가 오는 날까지 * 아무도 이 한계를 모른다. 훑는 건수를 상한으로 막고, 상한에 닿았으면 잘렸다고 말한다 * (알리지 않고 자르지 않는다). * * 말하는 자리는 `scanned` · `truncated` 다. 근거(`basis`)는 그대로 `rows` 이고 — 값이 어디서 * 왔는지는 훑었든 DB 가 골랐든 같은 행이다 — 계약이 닫아 둔 다섯 밖으로 나가지 않는다. */ const SCAN_MAX = 5000 const repo = getRepository(row.entity) const where = { domain: { id: domainId } as any, instance: { id: inst.id } as any } const rows = await repo.find({ where, order: row.order, take: SCAN_MAX }) const hit = rows.filter((r: any) => matches(this.rowItem(axis, r), q)) const answer: AxisListAnswer = { axis, label: info.label, basis: 'rows', total: hit.length, offset: skip, resourceClassLabels: this.classLabels(inst.model ?? {}), scanned: rows.length, truncated: rows.length >= SCAN_MAX, ...(() => { const items = hit.slice(skip, skip + take).map((r: any) => this.rowItem(axis, r)) return { items, names: namesFor(items, nameIndex(inst.model ?? {})) } })() } return answer } /* * **선언이 아닌 축** — 일정·실적(ISA-95 Part 4). 이 축의 집은 트윈 모델이 아니라 **커널 상태**다 * (커널이 `source: 'state'` 로 선언한다). * * ── 멈춘 트윈은 0 이 아니다 ────────────────────────────────────────────────── * 커널이 동작하지 않으면 관측이 **없다**. 그때 빈 목록을 내면 "작업이 0 건" 으로 읽히고, 그건 * 거짓이다 — 이 화면이 관측 열에서 내내 지켜 온 구분(관측 없음 ≠ 0)을 목록에서도 지킨다. * * ── 규모 ──────────────────────────────────────────────────────────────────── * 상태 배열은 지금의 사실만 담아 유한하다(진행 중 오더·작업). 과거까지 세는 것은 저널의 일이고 * 커서 페이징이 필요하다 — 그건 `historical` 축의 후속이며, 여기서 흉내내지 않는다. */ if (axisSource(axis) === 'state') { /* 봉투를 벗긴다 — `{revision, state}` 안에 축이 있다. 그리고 **실행 중일 때만** 관측이다. */ /* * **라이브는 권위, 히스토리는 파생.** * * 지금 도는 커널의 상태가 이 축의 진실이다. 그런데 커널이 멈춰 있거나 사용자가 **과거 시각**을 * 물으면 답할 것이 없어진다 — 그때 저널을 그 시점까지 계산해 답한다(`recover`). 둘은 같은 모양이지만 * **성질이 다르므로 `basis` 로 구별해 낸다**: 파생을 권위처럼 내면 다시 계산한 값을 보고 "지금 이렇다" * 로 읽게 된다. * * 규모: 계산 비용은 이벤트 수에 비례한다. 감추지 않고 **어느 리비전까지 계산했는지 함께 낸다** — * 이 화면이 목록마다 `basis` 를 밝혀 온 규율 그대로다. */ const live = TwinEngine.isRunning(domainId, instanceId) && !at_ let snap = live ? unwrapState(TwinEngine.snapshot(domainId, instanceId)) : null let folded: number | undefined if (!snap) { try { const rec = await TwinEngine.recover(domainId, instanceId, undefined, at_ ?? undefined) snap = unwrapState(rec) folded = Number((rec as any)?.revision) || undefined } catch { /* 다시 계산할 것이 없다(모델도 저널도 없다) — 없는 것을 0 으로 메우지 않는다. */ snap = null } } if (!snap) { return { axis, label: info.label, basis: 'state', total: null, observed: false, offset: skip, items: [] } } /* 축이 사는 자리는 **커널 선언이 말한다**(`path`) — 축 이름으로만 읽으면 중첩된 축이 언제나 비어 보인다. 카드는 `axisValueOf` 로 읽는데 여기가 그 규칙을 지나지 않아, 「42」를 센 개념의 목록이 「0건」으로 나왔다(에너지의 수요 구간). 규칙의 집은 `axisValueOf` 하나다. */ const bucket = axisValueOf(snap, info) const list: any[] = Array.isArray(bucket) ? bucket : [] const hitState = q ? list.filter(o => matches(o, q)) : list const items = hitState .slice(skip, skip + take) .map(o => ({ id: idOf(o, info), fields: this.withOrderMaterial(axis, inst.model ?? {}, o) })) return { axis, label: info.label, /* 권위(라이브)와 파생(저널)을 구별해 낸다 — 화면이 이 낱말을 그대로 보인다. */ basis: live ? 'state' : 'journal', ...(folded != null ? { foldedRevision: folded } : {}), ...(at_ ? { at: at_ } : {}), observed: true, total: hitState.length, offset: skip, resourceClassLabels: this.classLabels(inst.model ?? {}), items, names: namesFor(items, nameIndex(inst.model ?? {})) } } /* 아직 문서 안에 사는 축 — 문서를 읽어 자른다. 규모 한계를 `basis` 로 드러낸다. */ const raw = at(inst.model ?? {}, documentPath(axis)) const all = Array.isArray(raw) ? raw : raw ? [raw] : [] const hit = q ? all.filter(o => matches(o, q)) : all return { axis, label: info.label, basis: 'document', total: hit.length, offset: skip, resourceClassLabels: this.classLabels(inst.model ?? {}), ...(() => { /* * **한 벌뿐인 선언은 축 이름이 곧 식별자다.** * * `productionSpec` 처럼 문서에 객체 하나로 있는 축은 `id`/`key` 가 없다. 그것을 그대로 * 목록에 실으면 "식별자 없음 — 가리킬 수 없습니다" 라는 줄 하나가 뜨고, 정작 그 선언을 * 열어 볼 방법이 없다. 하나뿐인 것은 축 자신으로 가리킨다(상세도 같은 규칙으로 찾는다). */ const singular = !Array.isArray(raw) && !!raw const items = hit .slice(skip, skip + take) .map(o => ({ id: idOf(o) ?? (singular ? axis : null), fields: this.withBinding(axis, inst.model ?? {}, o) })) return { items, names: namesFor(items, nameIndex(inst.model ?? {})), singular } })() } } /** * 행에서 **내부 배관을 걷어낸다** — 사용자가 볼 것은 현장의 사실이지 우리 스키마가 아니다. * * 손으로 목록을 두지 않는다. 관계와 그 조인 컬럼이 무엇인지는 **엔티티 메타데이터**가 알고, * 표로 두면 컬럼이 늘 때마다 여기를 고쳐야 하며 잊으면 그대로 새어 나간다 — 실제로 * `domainId`·`instanceRowId` 같은 내부 UUID 가 화면까지 갔다. * * 식별자도 뺀다: 항목 자신의 id 는 이미 `id`/`itemId` 로 나가므로 두 번 보일 이유가 없다. */ private strip(entityClass: any, idColumn: string, r: any): any { const meta = getRepository(entityClass).metadata const hidden = new Set(['id', 'createdAt', 'updatedAt', 'creator', 'updater', 'creatorId', 'updaterId', idColumn]) for (const rel of meta.relations) { hidden.add(rel.propertyName) for (const jc of rel.joinColumns) hidden.add(jc.propertyName) } /* `@RelationId` 로 승격한 내부 UUID(`domainId`·`instanceRowId`·`areaRowId`) — 관계 목록에는 없고 여기에만 있다. 이 한 줄이 없어서 실제로 화면까지 샜다. */ for (const rid of meta.relationIds) hidden.add(rid.propertyName) const out: any = {} for (const [k, v] of Object.entries(r)) if (!hidden.has(k)) out[k] = v return out } /** * **자재는 자기 GS1 품목 참조를 안다** — 읽기 경계에서 이어 붙인다. * * 정본은 `productionSpec.binding` 하나다(커널이 EPC↔자재를 그 표로 잇는다). 그런데 그 사실은 * **자재마다의 속성**이다: 이 자재가 GS1 품목 몇 번인가, 그래서 개체로 추적되는가(= 계보를 비교할 * 수 있는가). 자재 목록에 그것이 없으면 자재는 자기 정체를 모르고, 사용자는 그 사실을 딴 축에서 * 찾아야 한다. * * 그래서 **저장은 한 곳(binding), 표시는 읽는 자리에서 이어 붙임** — 이름표와 같은 방식이다. * 값을 복제해 두면 두 곳이 어긋나고, 어긋난 쪽을 아무도 모른다. */ private withBinding(axis: string, model: any, fields: any): any { if (!BINDING_AXES.has(axis) || !fields || typeof fields !== 'object') return fields const binding: Record = model?.productionSpec?.binding ?? {} const ref = binding[fields.key ?? fields.id] /* 없으면 키를 만들지 않는다 — 빈 값이 있는 것과 선언되지 않은 것은 다르다. */ return ref ? { ...fields, gs1Ref: ref } : fields } /** * **오더가 무엇을 만드나** — GS1 품목 참조를 자재로 다시 계산해 붙인다. * * 커널의 오더는 품목을 GS1 으로 말한다(`urn:epc:idpat:sgtin:0614141.078101.*`). 사람에게 그 문자열은 * 아무 뜻이 없고, 그 품목의 이름은 **자재 선언**이 갖고 있다. 둘을 잇는 것은 `productionSpec.binding` * (자재 키 → 품목 참조)이므로 **역방향으로 다시 계산한다.** * * 정본은 그대로 둔다: `gtin` 은 커널이 말한 값이고, 여기서 더하는 것은 **자재 키 하나**다(그 이름은 * 이름표가 붙인다 — 값을 복제하지 않는다). 다시 계산하지 못하면 아무것도 더하지 않는다: 짐작으로 이으면 * "이 오더는 저것을 만든다" 는 거짓이 되고, 그건 모르는 것보다 나쁘다. */ private withOrderMaterial(axis: string, model: any, fields: any): any { if (axis !== 'orders' || !fields || typeof fields !== 'object') return fields const binding: Record = model?.productionSpec?.binding ?? {} const refToMaterial = new Map(Object.entries(binding).map(([mat, r]) => [String(r), mat])) /* 단일 품목 작업지시는 `gtin`, 여러 품목이면 라인마다 말한다 — 첫 라인으로 대표하지 않는다. */ /* 정체성 모양은 하나가 아니다 — 규칙의 집은 `item-ref` 하나다(웹 URI 원본에서 이 자리가 언제나 빈칸이었다). */ const refOf = itemRefOf const mat = refToMaterial.get(String(refOf(fields.gtin) ?? '')) if (mat) return { ...fields, material: mat } const lineMats = (fields.lines ?? []) .map((l: any) => refToMaterial.get(String(refOf(l?.gtin) ?? ''))) .filter(Boolean) return lineMats.length ? { ...fields, materials: [...new Set(lineMats)] } : fields } /** 행 하나 → 목록 항목. 컬럼 이름을 화면이 알지 않아도 되게 `id` + 나머지로 낸다. */ private rowItem(axis: string, r: any): any { const { entity, idColumn } = ROW_BACKED[axis] return { id: r[idColumn], fields: this.strip(entity, idColumn, r) } } @Query(returns => ScalarObject, { nullable: true, description: 'One item of a standard concept: what the source declared, what the twin observed, what it links to and is linked from (declared relations, both directions), and where it came from. Never invents a value for a field the source did not provide.' }) async twinModelItem( @Arg('instanceId') instanceId: string, @Arg('axis') axis: string, @Arg('itemId') itemId: string, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain.id const info = axisInfo(axis) if (!info) throw new Error(`unknown concept axis: "${axis}" — the kernel catalog does not declare it`) const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return null const model = inst.model ?? {} const row = ROW_BACKED[axis] let declared: any = null let provenance: any = null /* * **관측 축의 상세** — 일정·실적은 상태에서 찾는다. 커널이 멈춰 있으면 항목이 아니라 **모른다**를 * 낸다(그 트윈에 그 오더가 없다는 뜻이 아니다 — 지금 관측이 없다는 뜻이다). */ if (axisSource(axis) === 'state') { /* * 동작하지 않으면 **목록과 같은 길로 저널을 계산한다.** * * 목록만 계산하고 상세는 안 계산했더니, 목록에는 472건이 보이는데 그 줄을 열면 "자기 속성이 없습니다" * 가 나왔다 — 한 화면에서 두 답이 갈리면 어느 쪽이 맞는지 사용자가 알 수 없다. */ const liveItem = TwinEngine.isRunning(domainId, instanceId) let snap = liveItem ? unwrapState(TwinEngine.snapshot(domainId, instanceId)) : null if (!snap) { try { snap = unwrapState(await TwinEngine.recover(domainId, instanceId)) } catch { /* 다시 계산할 것이 없다 — 아래에서 "모른다" 를 낸다(0 으로 메우지 않는다). */ } } /* 같은 규칙 — 축 이름이 아니라 선언된 자리에서 읽는다(§axisValueOf). */ const bucket = snap ? axisValueOf(snap, info) : null const found = Array.isArray(bucket) ? bucket.find((o: any) => idOf(o, info) === itemId) : undefined /* * **상태를 읽었는데 그 개체가 없으면, 그런 개체가 없는 것이다** (2026-09-18). * * 이 갈래가 `!snap || !found` 하나였다. 그래서 없는 식별자로 물어도 「트윈이 실제로 본 것입니다」 * 라고 적힌 패널이 돌아왔다 — 화면은 그것을 그려서, 있지도 않은 개체가 값이 비어 있는 개체처럼 * 보였다. 문서 축은 같은 물음에 `null` 을 답한다(없는 것은 없다고 답한다). 상태 축도 같다. * * 못 읽은 것은 다른 사실이라 아래에서 따로 답한다 — 「없다」로 접지 않는다. */ if (snap && !found) return null if (!snap) { return { instanceId, axis, label: info.label, itemId, identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null, journalAxis: info.journalAxis ?? null, describedBy: info.describedBy ?? null }, declared: null, observed: null, observedOnly: true, /* * **왜 관측 열이 비었나** — 커널이 멈춰 있고 저널로도 되세우지 못했다. 코드는 목록·비교 열이 * 쓰는 것과 같은 것을 쓴다(`not-running`) — 같은 사실에 두 낱말을 만들지 않는다. */ observedAbsence: 'not-running' as const, basis: liveItem ? 'state' : 'journal', observedBasis: liveItem ? 'state' : 'journal', observing: liveItem, links: { out: [], incoming: [] }, properties: propertiesOf(axis).map((pr: any) => ({ key: pr.key, label: pr.label, kind: pr.kind, uom: pr.uom ?? null, observedBy: pr.observedBy ?? null })), /* 어떤 속성이 **효과가 있나** — 등록부가 말한다(§property-effects). 없는 것은 표시만 된다. */ propertyEffects: propertyEffectsOf(axis), /* * 출처는 이 답의 `basis` 와 같은 규칙이다(아래 읽은 갈래도 같다). 여기를 `state` 로 박아 두어서, 커널이 멈춰 * 저널로 되세우려던 답이 「지금 상태에서 읽었다」고 말했다(ADR-0075 결정 3 · 결함 넘김 2026-09-24). * 아무것도 못 읽었다는 사실은 `observedAbsence` 가 말한다 — 출처를 비우지 않는다(`READ_BASIS` 주석). */ names: {}, provenance: { basis: liveItem ? 'state' : 'journal' } } } const names = namesFor([{ id: itemId, fields: found }], nameIndex(inst.model ?? {})) return { instanceId, axis, label: info.label, itemId, identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null, journalAxis: info.journalAxis ?? null, describedBy: info.describedBy ?? null }, /* * **선언이 없는 축이다.** 두 열 중 왼쪽이 비는 것이 정상이고, 그 사실을 값으로 말한다 * (`observedOnly`) — 빈 열을 "원본이 침묵했다" 로 읽으면 안 된다. 원본은 애초에 이 축을 * 선언하지 않는다. */ declared: null, observed: this.withOrderMaterial(axis, inst.model ?? {}, found), observedOnly: true, basis: liveItem ? 'state' : 'journal', /* 관측 열의 근거는 **한 이름으로** 낸다 — 두 이름을 두면 화면이 한쪽을 잊는다. */ observedBasis: liveItem ? 'state' : 'journal', observing: liveItem, properties: propertiesOf(axis).map((pr: any) => ({ key: pr.key, label: pr.label, kind: pr.kind, uom: pr.uom ?? null, observedBy: pr.observedBy ?? null })), /* 어떤 속성이 **효과가 있나** — 등록부가 말한다(§property-effects). 없는 것은 표시만 된다. */ propertyEffects: propertyEffectsOf(axis), links: await this.linksOf(domainId, inst, axis, itemId, found), resourceClassLabels: this.classLabels(inst.model ?? {}), names, provenance: { basis: liveItem ? 'state' : 'journal' } } } if (row) { const found: any = await getRepository(row.entity).findOne({ where: { domain: { id: domainId } as any, instance: { id: inst.id } as any, [row.idColumn]: itemId } as any }) if (found) { const { sourceRef, ingestedAt, ...rest } = this.strip(row.entity, row.idColumn, found) declared = rest /* 출처는 속성이 아니라 **근거**다 — 두 열이 아니라 아래 근거 묶음으로 간다. */ provenance = { sourceRef, ingestedAt, basis: 'rows' } } } /* * 행에 없으면 문서를 본다 — **투영이 낡은 트윈에서도 항목을 볼 수 있어야 한다.** 행만 보고 * "없다" 고 답하면, 문서에는 있는 자리가 화면에서 사라진다(이 프로젝트가 내내 잡아 온 부류). */ if (!declared) { const raw = at(model, info.path) const all = Array.isArray(raw) ? raw : raw ? [raw] : [] /* 한 벌뿐인 선언은 축 이름으로 찾는다 — 목록이 그 이름으로 가리켰기 때문이다. */ const found = all.find(o => idOf(o) === itemId) ?? (!Array.isArray(raw) && raw && itemId === axis ? raw : undefined) if (found) { declared = typeof found === 'string' ? { id: found } : this.withBinding(axis, model, found) provenance = { basis: 'document' } } } /* 선언에도 문서에도 없다 — 그런데 **관측에는 있을 수 있다**(원본이 모르는 자원). 그 경우도 항목으로 열려야 한다. 둘 다 없으면 진짜로 없는 것이므로 `null`. */ const seen = await this.observedOf(domainId, instanceId, axis, itemId) const observed = 'value' in seen ? seen.value : null if (!declared && !observed) return null /* 이웃을 먼저 구한다 — 이름표가 이웃의 id 까지 덮으려면 반환 전에 손에 있어야 한다. */ const links = await this.linksOf(domainId, inst, axis, itemId) /* * **구역 이름은 모델에 없다** — 구역은 호스트 개념이고(`TwinArea`), 인제스트가 모델에서 갈라 * 공간 쪽으로 보낸다. 그래서 모델만 조회하는 이름표에는 `a-floor` 의 이름이 없고, 관계 그림에 그 id 가 * 그대로 섰다. 그 행에서 가져와 이름표에 합친다(이 트윈의 공간 것만). */ const areaNames: Record = {} if (inst.spaceId) { const areas = await getRepository(TwinArea) .find({ where: { domain: { id: domainId } as any, space: { spaceId: inst.spaceId } as any } as any }) .catch(() => [] as any[]) for (const a of areas as any[]) if (a?.areaId && a?.name) areaNames[a.areaId] = a.name } return { instanceId, axis, label: info.label, itemId, /* 정체 — 표준 클래스·식별 스킴은 전부 카탈로그 선언에서 온다. */ identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null, /* * 선언 둘을 그대로 나른다 (ADR-0074) — 이 개체의 이력을 저널의 어느 축으로 묻나, * 그리고 식별자 말고 무엇이 이 개체를 말하나. 화면이 그 목록을 갖지 않게 한다. */ journalAxis: info.journalAxis ?? null, describedBy: info.describedBy ?? null }, /* * **그릇인가** — 문서에 객체 하나로 있는 축(`productionSpec`)인지. * * `kind` 로는 갈리지 않는다: 커널은 라우트·레시피·공정도 `spec` 이라 부른다(같은 선언 부류라는 * 뜻). 화면이 `kind` 로 갈랐더니 **라우트 상세가 그릇 틀로 그려져** 가장 중요한 정보(공정 순서)가 * 개수 칩 하나로 줄어들었다. 그릇인지 아닌지는 모양이 말한다. */ container: axisSource(axis) === 'document' && !Array.isArray(at(model, info.path)) && at(model, info.path) != null, /* 두 열 — 원본이 말한 값, 트윈이 관측한 값. 한쪽이 없으면 **없다고 낸다**(0 으로 메우지 않는다). */ declared, observed, /* * 관측 열이 **자기 근거를 말한다** — 목록의 그 낱말 그대로(`state`=라이브 권위, `journal`=다시 계산한 * 파생). 없으면 비교하지 못한 것이다. 파생이면 몇 번째 리비전까지 계산했는지 함께 낸다. */ ...('value' in seen ? { observedBasis: seen.basis, ...(seen.foldedRevision != null ? { observedRevision: seen.foldedRevision } : {}) } : { observedAbsence: seen.absence }), /* 선언된 속성 — 어느 관측 축에서 오는지도 함께(카탈로그가 말한 대로). */ properties: propertiesOf(axis).map((p: any) => ({ key: p.key, label: p.label, kind: p.kind, uom: p.uom ?? null, observedBy: p.observedBy ?? null })), /* * **효과가 있는 속성과 표시만 되는 속성은 다르다.** 속성은 열려 있어 아무 사실이나 실어 보낼 수 * 있지만, 값이 계산을 바꾸려면 그 규칙을 코드가 알아야 한다. 지금 무엇이 그런지 화면이 말할 수 * 있게 등록부를 함께 낸다 — 사용자가 넣고 나서 아무 일도 안 일어나는 이유를 알 수 있게. */ propertyEffects: propertyEffectsOf(axis), links, /* * **등급 이름은 등급이 갖고 있다.** 공정은 자격을 등급 id 로만 요구하므로(표준이 그렇다), * 그 id 를 그대로 화면에 내면 `assembler-op` 이 사용자에게 간다. 등급의 표시명은 등급 선언의 * `description`(i18n 키)에 있고 그것은 **다른 축**이다 — 화면이 축을 하나 더 읽지 않아도 되게 * 여기서 이어 붙인다(서버는 이미 모델을 손에 들고 있다). */ resourceClassLabels: this.classLabels(model), /* * **공정이면 그 공정의 능력도 함께 낸다** — ISA-95 Part 4 `OperationsCapability`. * * 공정 상세에서 사람이 묻는 것은 "이 공정이 지금 얼마나 더 할 수 있나" 이고, 그 답은 명세만 * 봐서는 안 나온다(약정과 지금 못 쓰는 자원을 빼야 한다). 화면이 축을 하나 더 읽지 않게 * 여기서 이어 붙인다 — 이름표·등급 라벨을 붙이는 것과 같은 이유다. */ ...(await this.capabilityOfOperation(domainId, instanceId, axis, itemId)), /* * 상세도 같은 표를 쓴다 — 목록과 상세가 같은 이름으로 말해야 사용자가 같은 것으로 읽는다. * * **이웃의 이름도 함께 싣는다.** 관계 그림의 이웃은 대개 *들어오는* 링크(이 자리를 기준 자리로 * 삼는 설비·사람)라, 이 항목 자신의 필드에는 그 id 가 없다 — 필드만 훑으면 그림이 id 로 채워진다. */ names: namesFor( [{ id: itemId, fields: declared }, { fields: observed }, { fields: links }], { ...nameIndex(model), ...areaNames } ), provenance } } /** * **id → 이름**, 모든 축을 한 표로. * * ── 왜 이 표가 필요한가 ────────────────────────────────────────────────────── * 행은 다른 것을 **id 로** 가리킨다(사람의 `personnelClassIds`, 설비의 `homeLocationId`, 공정의 * `locationType`). 그 id 의 이름은 **가리켜진 축의 행**에 있으므로, 화면이 그것을 알려면 축을 * 하나씩 더 읽어야 한다 — 그래서 지금까지 표면마다 그냥 id 를 찍었다(`cutter-op`, `cut-station`). * 표면을 하나씩 고치는 것은 끝이 없다: 새 화면은 처음부터 다시 id 를 찍는다. * * 그래서 **서버가 붙인다.** 서버는 이미 모델을 손에 들고 있고, 어느 축이 있는지는 커널 카탈로그가 * 선언한다(`TWIN_AXES`) — 도메인이 늘어도 이 함수는 그대로다. * * 값은 그 항목이 **자기 이름이라고 말한 것**이다(`name`·`label`·`description`). 그중 어떤 것은 * i18n 키이므로(등급의 `description`) 번역은 화면의 일로 남긴다. 이름이 없으면 표에 넣지 않는다 — * 없는 것을 지어내지 않고, 그 경우 화면은 id 를 그대로 보인다. */ private nameIndex(model: any): Record { const out: Record = {} for (const info of TWIN_AXES as any[]) { /* 관측 축(상태·저널)은 문서에 없다 — 경로가 없는 축을 읽으면 모델 루트를 훑는다. */ if (axisSource(info.axis) !== 'document') continue const raw = at(model ?? {}, info.path) const list = Array.isArray(raw) ? raw : raw ? [raw] : [] for (const o of list) { const id = idOf(o) if (!id || typeof o !== 'object') continue const label = o.name ?? o.label ?? o.description /* 이름이 id 와 같으면 표에 넣지 않는다 — 같은 값을 두 번 보이는 셈이다. */ if (typeof label === 'string' && label && label !== id && !out[id]) out[id] = label } } return out } /** * 이 응답이 **실제로 가리킨 것만** 골라 낸다. * * 모델 전체 표를 그대로 실으면 자리가 10만인 현장에서 목록 한 페이지에 10만 줄짜리 이름표가 * 따라온다. 나온 값에 걸리는 것만 싣는다 — 규모에서 성립하는 유일한 방식이다. */ private namesFor(rows: any[], index: Record): Record { const out: Record = {} const take = (v: any, depth: number): void => { if (v == null || depth > 4) return if (typeof v === 'string') { if (index[v]) out[v] = index[v]; return } if (Array.isArray(v)) { for (const x of v) take(x, depth + 1); return } if (typeof v === 'object') for (const x of Object.values(v)) take(x, depth + 1) } for (const r of rows) { take(r?.fields ?? r, 0); if (typeof r?.id === 'string' && index[r.id]) out[r.id] = index[r.id] } return out } /** 등급 id → 표시명 키. 인원·설비·자산·품목 등급을 한 표로 — 어느 자원이든 같은 방식으로 요구된다. */ private classLabels(model: any): Record { const out: Record = {} for (const key of ['personnelClasses', 'equipmentClasses', 'assetClasses', 'materialClasses']) /* 표시명은 `name` 이 정본이다 — 옛 모델는 `description` 에 담고 있어 둘 다 본다(읽기 경계 하나). */ for (const c of (model?.[key] ?? []) as any[]) if (c?.id && (c.name || c.description)) out[c.id] = c.name || c.description return out } /** * 관측이 본 이 항목 — 커널이 실행 중일 때만. **실행 중이 아니면 `null` 이지 빈 객체가 아니다** * (관측이 없는 것과 비교하지 못한 것은 다르다). */ /** * 이 공정의 **생산 능력** — 공정 축 항목에만 붙는다. * * 구간은 **앞으로 24시간**이다. 표준의 약정·가용은 *앞으로 쓸* 용량이므로 지난 구간을 두고 말하면 * 뜻이 없고, 이 화면에는 사용자가 고른 창이 없다. 그래서 하루를 쓰고 **그 사실을 값과 함께 낸다** * (화면이 라벨로 말한다) — 어느 구간의 값인지 모르면 능력은 읽을 수 없다. * * **수요를 지어내지 않는다.** 여기서 내는 값(상한·약정·달성불가·가용·제약하는 축)은 전부 공급 쪽이다 — * 자원 수·사이클·가동률에서 나온다. 수요는 `ok`·병목·하루 상한에만 쓰이고 그것들은 내지 않으므로, * 계산에 넘기는 수요 `1` 은 결과에 새지 않는다(성과 화면은 사용자가 걸어 둔 목표를 쓴다 — 그 화면은 * "목표를 낼 수 있나" 에 답하고 여기서는 "얼마나 더 할 수 있나" 에 답한다). * * 안 실행 중이면 붙이지 않는다 — 능력은 지금 자원의 사실에서 나오고, 없는 것을 지어내지 않는다. */ private async capabilityOfOperation( domainId: string, instanceId: string, axis: string, itemId: string ): Promise<{ capability?: any } > { if (axis !== 'operations') return {} if (!TwinEngine.isRunning(domainId, instanceId)) return {} const from = new Date() const to = new Date(from.getTime() + 24 * 3_600_000) const monday = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - ((from.getUTCDay() + 6) % 7)) try { const rows = await TwinEngine.operationsCapability( domainId, { instanceId }, 1, monday, { fromTime: from.toISOString(), toTime: to.toISOString() } ) const report = rows.find((r: any) => r.instanceId === instanceId)?.report const entry = report?.perOperation?.find((o: any) => o.operation === itemId) if (!entry) return {} /* 병목·상한은 **넣지 않는다** — 그 둘만 수요에 달려 있고, 여기서는 수요를 모른다. */ return { capability: { window: report.window, operationsType: report.operationsType, ...entry } } } catch { /* 능력을 못 냈다고 상세가 안 열릴 이유는 없다 — 없으면 없는 대로 낸다(화면이 그 줄을 안 그린다). */ return {} } } /** * 이 항목을 트윈이 **관측한 값** — 그리고 **무엇을 근거로** 아는지. * * 봉투를 푸는 것을 빠뜨려서(`unwrapState`) 문서 축의 관측 열이 통째로 비어 있던 자리다(사람을 열면 * 트윈이 아는 것이 전부 `—` 였다). * * 근거를 값과 함께 낸다 — 목록이 오래 해 온 그대로다(§twinModelItemList). 라이브(권위)와 저널 * 폴드(파생)는 **같은 모양의 객체**라서, 근거가 없으면 다시 계산한 값을 "지금 이렇다" 로 읽는다. 근거를 * 실을 자리가 없던 동안에는 멈춘 트윈의 저널을 아예 계산하지 않고 `—` 로 두는 편이 정직했다. 이제 * 자리가 있으니 계산한다 — 멈춘 트윈에도 마지막으로 알던 사실이 있고, 그것을 감출 이유는 없다. * * 다시 계산할 것이 없으면(모델도 저널도 없다) `null` 이다 — **없는 것과 비교하지 못한 것은 다르다.** */ private async observedOf( domainId: string, instanceId: string, axis: string, itemId: string ): Promise<{ value: any; basis: 'state' | 'journal'; foldedRevision?: number } | { absence: 'not-running' | 'not-observed' }> { const live = TwinEngine.isRunning(domainId, instanceId) let state: any = null let folded: number | undefined if (live) { state = unwrapState(TwinEngine.snapshot(domainId, instanceId)) } else { try { const rec = await TwinEngine.recover(domainId, instanceId) folded = Number((rec as any)?.revision) || undefined /* * **이벤트를 하나도 계산하지 않은 폴드는 관측이 아니다.** 저널이 비어도 복원은 선언을 다시 투영한 * 기준선을 돌려준다 — 그것을 "트윈이 관측한 값" 열에 실으면 **선언이 관측으로 위장한다** * (원본이 말한 값이 오른쪽에도 똑같이 앉아, 견준 것처럼 보인다). 계산한 것이 없으면 없다고 한다. */ if (folded == null) return { absence: 'not-running' as const } state = unwrapState(rec) } catch { return { absence: 'not-running' as const } } } /* 같은 규칙 — 선언된 자리에서 읽는다. 축 이름으로 읽으면 중첩된 축이 언제나 「관측 없음」이 된다. */ const bucket = state ? axisValueOf(state, axisInfo(axis) ?? { axis }) : null /* * **왜 관측이 없는지 말한다.** 예전에는 그냥 `null` 이었고 화면은 그것을 "트윈이 실행 중이지 않아" * 로 읽었다 — 실행 중인데도 그렇게 말했다(공정 상세에서 관측됨). 공정·레시피처럼 **관측 축이 * 아닌 것**은 트윈이 돌든 말든 관측이 없다. 이유가 다르면 화면이 다르게 말해야 한다. */ if (!Array.isArray(bucket)) return { absence: 'not-observed' as const } /* 같은 규칙 — 선언이 말하는 자리로 찾는다. `id` 로만 찾으면 수요 구간을 열 수 없다. */ const value = bucket.find((x: any) => idOf(x, axisInfo(axis)) === itemId) if (!value) return { absence: 'not-observed' as const } return { value, basis: live ? 'state' : 'journal', ...(folded != null ? { foldedRevision: folded } : {}) } } /** * 이어진 것 — **양방향.** 선언된 관계(`TWIN_RELATIONS`)만 따라간다. * * 관계를 코드가 알지 않으므로 도메인이 늘어도 여기 손댈 것이 없다. 대신 **선언에 없는 관계는 * 그리지 않는다** — 지도에 없는 길을 그리는 것보다 없다고 두는 것이 정직하다. * * 들어오는 쪽(누가 나를 가리키나)은 그 축의 항목을 살펴야 안다. 규모 규율상 **깊이 1 · 방향별 * 상한**이고, 넘치면 몇이 더 있는지 말한다(목록으로 보낼 수 있게). */ private async linksOf( domainId: string, inst: any, axis: string, itemId: string, /* * 이 항목 자신 — **상태 축은 문서에서 찾을 수 없다.** 부르는 쪽이 이미 손에 들고 있으므로 넘겨 * 받는다. 없으면 예전처럼 문서에서 찾는다(문서 축의 거동은 그대로). */ self0?: any ): Promise { const NEIGHBOR_MAX = 20 const model = inst.model ?? {} /* * 관측 축의 항목들 — **한 번만** 읽는다(관계마다 다시 계산하면 저널을 여러 번 계산한다). * 라이브가 있으면 그것, 없으면 저널 폴드. 없으면 빈 배열(없는 것을 지어내지 않는다). */ let observedState: any let observedLoaded = false const observedBucket = async (a: string): Promise => { if (!observedLoaded) { observedLoaded = true try { observedState = TwinEngine.isRunning(domainId, inst.instanceId) ? unwrapState(TwinEngine.snapshot(domainId, inst.instanceId)) : unwrapState(await TwinEngine.recover(domainId, inst.instanceId)) } catch { observedState = null } } const b = observedState ? (observedState as any)[a] : null return Array.isArray(b) ? b : [] } /* * **관계는 선언 어휘로 푼다** — 문서에서 이 항목을 찾아 그것을 본다. * * 관계 선언의 필드 이름(`locations.parentId`)은 **원본 문서의 어휘**다. 행은 같은 사실을 자기 * 컬럼 이름으로 담는다(자리의 구역은 `area` FK). 행을 보고 `parentId` 를 찾으면 늘 비어서 * "이어진 것이 없다" 로 알리지 않고 답한다 — 실제로 그랬다. * * 어느 쪽이 정본인가로 구분하면 답이 분명하다: 관계를 선언한 어휘가 문서 어휘이므로 **문서에서 * 푼다.** 행에만 있고 문서에 없는 항목(있을 수 있다)은 이어진 것이 비지만, 그것은 사실이다. */ const axisPath = axisSource(axis) === 'document' ? axisInfo(axis)?.path : undefined const rawSelf = axisPath ? at(model, axisPath) : undefined const selfAll = Array.isArray(rawSelf) ? rawSelf : rawSelf ? [rawSelf] : [] const self = self0 ?? selfAll.find((o: any) => idOf(o) === itemId) /* * ── 한 필드가 두 곳을 가리킬 때 (계약 0.9.24, 2026-09-17) ────────────────────── * `locations.parentId` 는 다른 자리(라인 · 셀 · 존)이거나, 그 id 의 자리가 없으면 공간의 구역이다 * (`axis-or-external`). 어느 쪽인지는 **조회로** 갈린다 — 규칙은 계약의 `splitRelationIds` 하나다. * * 화면에는 두 줄로 낸다: 자리 쪽은 그 자리로 걷는 링크(`target.kind: 'axis'`), 나머지는 구역으로 * 풀린 것(`external`). 한 줄에 섞으면 화면이 그 줄 전체를 외부로 그리거나 전체를 링크로 그려 둘 중 * 하나가 틀린다. 선언은 `declaredTarget` 으로 함께 남긴다. */ const axisIdSets = new Map>() for (const r of relationsFrom(axis)) { if (r.target?.kind !== 'axis-or-external' || axisIdSets.has(r.target.axis)) continue const tInfo = axisInfo(r.target.axis) const raw = tInfo && axisSource(r.target.axis) === 'document' ? at(model, tInfo.path) : await observedBucket(r.target.axis) const all = Array.isArray(raw) ? raw : raw ? [raw] : [] axisIdSets.set(r.target.axis, new Set(all.map((o: any) => idOf(o)).filter(Boolean).map(String))) } const inAxis = (a: string, id: string) => axisIdSets.get(a)?.has(id) ?? false /* 나가는 쪽 — 내 필드가 가리키는 것. 선언이 필드 이름을 알려 준다. */ const out = relationsFrom(axis).map((r: any) => { const raw = self ? this.fieldValue(self, r.field) : [] const ids = raw.filter((v: any) => v != null).map(String) return { via: r.via, field: r.field, optional: !!r.optional, /* 대상이 축이면 그 축의 항목으로 갈 수 있다. 타입·외부(구역)면 **갈 곳이 다르다** — 화면이 링크를 만들지 말지 알아야 하므로 대상 종류를 그대로 낸다(막다른 링크 금지). */ target: r.target, /* * ── 언제의 일인가, 그리고 담아 둬도 되나 (2026-09-06) ──────────────── * 판정을 계약이 낸다. 화면이 `standing` 을 보고 스스로 정하면 값이 하나 늘 때 한 화면만 * 따라간다. UX 레인이 이 칸을 요청했고, 그쪽에 있던 규칙은 걷었다. * * `tense` 가 없는 관계도 `cacheable: false` 다. 모를 때는 다시 조회하는 쪽이 낫다 — * 낡은 값을 자신 있게 보여 주는 것이 비어 있는 것보다 나쁘다. 그래서 `tense` 는 없을 수 * 있고 `cacheable` 은 언제나 있다. */ tense: tenseOf(r), cacheable: isCacheableTense(tenseOf(r)), ids: ids.slice(0, NEIGHBOR_MAX), total: ids.length } }).flatMap((l: any) => { if (l.target?.kind !== 'axis-or-external') return [l] const all = self ? this.fieldValue(self, l.field).filter((v: any) => v != null).map(String) : [] const split = splitRelationIds(l.target, all, inAxis) const part = (target: any, ids: string[]) => ({ ...l, target, declaredTarget: l.target, ids: ids.slice(0, NEIGHBOR_MAX), total: ids.length }) const parts = [ ...(split.axis?.ids.length ? [part({ kind: 'axis', axis: split.axis.axis }, split.axis.ids)] : []), ...(split.external?.ids.length ? [part({ kind: 'external', entity: split.external.entity }, split.external.ids)] : []) ] /* 비어 있는 관계도 낸다(아래 주석) — 가리키는 것이 없으면 선언 그대로 한 줄. */ return parts.length ? parts : [l] }) /* * ── 밖을 가리키는 관계를 푼다 (2026-09-05) ───────────────────────────────── * 계약이 `kind: 'external'` 을 선언하며 「해소는 호스트가 한다」고 적어 두었는데 **부르는 곳이 * 0이었다.** 그래서 화면은 id 만 받고 그것이 무엇인지, 갈 수 있는지 몰랐다. * * 못 풀면 **못 푼다고 말한다** — 링크를 지우면 화면이 「그런 관계가 없다」로 읽고, 실제로는 * 「아무도 답할 수 없다」다. 사람이 할 일이 다르다. */ for (const l of out) { if (l.target?.kind !== 'external' || !l.ids.length) continue const got = await resolveExternal(l.target.entity, domainId, l.ids, { instanceId: inst.instanceId }, m => twinWarn(m)) if (got.refs) (l as any).refs = got.refs else (l as any).unresolved = got.unresolved } /* * **비어 있는 관계도 낸다.** 예전에는 `total > 0` 만 냈다 — 그러면 사람은 없다는 것조차 모른다. * * 실측으로 그 값이 드러났다(2026-09-05): 설비 64대에 시험 규격이 전부 비어 있는 것을 「빈 묶음」 * 으로 세워 두니 **「이 설비에 검사 기준이 없다」와 「화면이 그것을 안 본다」가 구별됐다.** 지우면 * 그 구별이 사라진다. * * 선택이 아닌 관계(`optional: false`)가 비어 있으면 그것은 **끊어진 참조**이고, 그 사실이 화면에 * 닿아야 한다. */ /* 들어오는 쪽 — 나를 가리키는 항목들. 그 축이 행이면 DB 가 세고, 문서면 문서를 전수 조회한다. */ const incoming: any[] = [] /* * 밖에서 오는 것을 함께 싣는다 — 제품이 자기 저장소에서 모은다(커널은 그 표를 모른다). * 꽂힌 해소기가 없으면 빈 배열이고 그것은 정상이다. */ incoming.push(...(await externalIncoming(domainId, inst.instanceId, axis, itemId, m => twinWarn(m)))) for (const r of relationsTo(axis)) { const fromInfo = axisInfo(r.from) if (!fromInfo) continue const hits: string[] = [] /* * 행은 `axis` 관계만 담는다. `axis-or-external`(자리 → 부모 자리 또는 구역)은 행에 구역 FK(`area`)만 * 있고 부모 **자리**를 담는 칸이 없어서, 행에서 세면 「이 존 아래 자리들」이 조용히 0이 된다. * 그 관계는 선언 어휘가 사는 문서에서 센다. */ const row = r.target?.kind === 'axis' ? ROW_BACKED[r.from] : undefined if (row) { const repo = getRepository(row.entity) /* * **FK 는 부르지 않으면 오지 않는다.** 관계 필드를 로딩하지 않고 조회하면 값이 늘 `undefined` * 라서 "나를 가리키는 것이 하나도 없다" 로 알리지 않고 답한다 — 실제로 그랬다(설비 둘이 이 자리를 * 기준으로 삼는데 이웃이 비어 있었다). * * 어느 필드가 관계인지는 **엔티티 메타데이터**가 안다. 축마다 표를 두면 축이 늘 때마다 * 여기를 고쳐야 하고, 고치는 것을 잊으면 같은 침묵이 다시 생긴다. */ const relationNames = new Set(repo.metadata.relations.map(rel => rel.propertyName)) const field = r.field.replace(/\[\]$/, '').split('.')[0] const rows: any[] = await repo.find({ where: { domain: { id: domainId } as any, instance: { id: inst.id } as any }, ...(relationNames.has(field) ? { relations: [field] } : {}), take: 5000 }) for (const x of rows) { /* 행에서는 관계가 FK 객체이거나 컬럼이다 — 이름 후보를 순서대로 본다. */ const v = this.relationValueOfRow(x, r.field, axis) if (v.includes(itemId)) hits.push(x[row.idColumn]) } } else { /* * 들어오는 쪽을 훑는다 — **어디를 훑을지는 그 축의 `source` 가 정한다.** * * 문서 축은 모델에서, 관측 축은 **상태에서** 훑는다(작업 → 이 오더, 작업 → 이 자리). 한동안 * 관측 축을 건너뛰었더니 오더 상세가 "닿는 관계가 없습니다" 라고 답했다 — `tasks.orderId` 를 * 선언해 두고도 그 관계를 그리지 않은 것이다. 선언한 것을 그리지 않으면 선언이 거짓이 된다. * * 규모: 상태는 지금의 사실만 담아 유한하고, 상한과 총계는 문서 축과 같은 규율을 쓴다 * (넘치면 몇이 더 있는지 말한다). 저널 전체를 전수 조회하는 일은 여기서 하지 않는다. */ const raw = axisSource(r.from) === 'document' ? at(model, fromInfo.path) : (await observedBucket(r.from)) const all = Array.isArray(raw) ? raw : raw ? [raw] : [] for (const x of all) if (this.fieldValue(x, r.field).map(String).includes(itemId)) { const id = idOf(x) if (id) hits.push(id) } } /* * **들어오는 쪽도 빈 것을 낸다** — 나가는 쪽과 같은 이유다(위 주석). * * 「이 설비를 가리키는 작업이 없다」는 사실이고, 그것을 지우면 화면은 그 물음을 물어보지도 * 못한다. 축당 들어오는 관계는 0~8개다(실측 2026-09-05: 시험규격 8 · 자리 6 · 나머지 2 이하) * — 다 내도 읽을 수 있는 분량이다. * * 값도 안 든다. 행 축은 0을 알아내려면 이미 `find` 를 돌렸고, 그 0을 말하는 비용은 없다. * * `externalIncoming` 은 여기서 예외다 — 그쪽의 빈 묶음은 「그 제품이 이 축을 안 본다」는 * 뜻이고 우리 선언에 없는 관계이므로 할 말이 없다. */ incoming.push({ via: r.via, from: r.from, field: r.field, tense: tenseOf(r), cacheable: isCacheableTense(tenseOf(r)), ids: hits.slice(0, NEIGHBOR_MAX), total: hits.length }) } return { out, incoming } } /** * 선언 필드 경로 → 값들. `inputs[].material` 처럼 **배열을 지나는 경로**를 다룬다. * 배열 표기(`[]`)가 관계 선언의 문법이므로 그것을 따르는 것이 곧 선언을 존중하는 것이다. */ private fieldValue(obj: any, field: string): any[] { let cur: any[] = [obj] for (const seg of field.split('.')) { const isArray = seg.endsWith('[]') const key = isArray ? 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.map(v => (typeof v === 'object' && v !== null ? idOf(v) ?? v : v)).filter(v => v != null) } /** * 행에서 관계 값을 꺼낸다 — FK 는 객체(관계 미로딩이면 없음)이고 문서 필드명과 컬럼명이 다를 수 있다. * * 여기서 컬럼 이름을 **축마다 표로 두지 않는다.** 후보를 순서대로 보는 편이 축이 늘 때 고칠 것이 * 없다: 관계 필드 이름 그대로 · 그 이름의 FK 객체의 id 컬럼. */ private relationValueOfRow(rowObj: any, field: string, targetAxis: string): string[] { const key = field.replace(/\[\]$/, '').split('.')[0] const v = rowObj[key] if (v == null) return [] if (typeof v === 'string') return [v] if (Array.isArray(v)) return v.map(x => (typeof x === 'string' ? x : idOf(x) ?? '')).filter(Boolean) /* FK 객체 — 대상 축의 id 컬럼으로 읽는다(관계를 로딩하지 않았으면 값이 없고, 그것도 사실이다). */ const idCol = ROW_BACKED[targetAxis]?.idColumn const got = idCol ? v[idCol] : idOf(v) return got ? [String(got)] : [] } }