/* * **board 밖을 가리키는 관계를 푸는 자리** — 선언은 있고 코드가 0곳이던 이음새. * * ── 무엇이 있었나 (2026-09-05 실측) ──────────────────────────────────────── * 계약이 `target: { kind: 'external', entity: … }` 를 선언하고 주석에 「해소는 호스트가 한다」고 * 적어 두었다. 그런데 **`headless-twin/server` 어디에도 `kind === 'external'` 을 다루는 코드가 * 없었다**(0곳). * * `space.area` 가 도는 것처럼 보인 이유는 이음새가 있어서가 아니라 **호스트에서 그것이 평범한 FK * 라서**다. TypeORM 메타데이터가 우연히 답해 준 것이지 선언이 길을 낸 것이 아니다. * * ── 왜 지금 채우나 ───────────────────────────────────────────────────────── * 아침에는 「아무도 안 막혔으니 급하지 않다」로 적었다. **그 판단이 규모를 작게 잡은 것이었다.** * * 360(한 대상에 걸린 것을 한자리에)의 소비처는 무한히 늘어난다. 그러면 각 제품의 자기 저장소가 * 관계의 원천이 된다 — MES 의 실적·상태 구간·정비 계획이 그렇고, 다음 제품도 그럴 것이다. * * 그때 **선언은 「밖을 가리킨다」고 말하는데 푸는 길이 없으면 제품마다 자기 길을 낸다.** 그것이 * 정확히 「무한히 갈린다」다. 이 파일이 그 갈림을 막는다. * * ── 커널은 어느 표인지 모른다 ────────────────────────────────────────────── * 계약은 「이 축이 밖을 가리킨다」까지만 안다. **「어느 표의 어느 칸」은 제품이 안다.** 그래서 제품이 * 부팅에서 해소기를 꽂는다 — 장부 기록기(`installIngestWindowWriter`)와 같은 규율이다. * * 커널이 표 이름을 알게 되면 한 제품의 저장 구조에 매이고, 다음 제품이 다른 표를 쓰면 커널을 또 * 고쳐야 한다. * * ── 없는 것을 숨기지 않는다 ──────────────────────────────────────────────── * 해소기가 없으면 **그 사실을 낸다**(`unresolved`). 링크를 지우면 화면은 「그런 관계가 없다」로 * 읽고, 실제로는 「아무도 답할 수 없다」다. 그 둘은 사람이 할 일이 다르다. */ /** 밖에 사는 대상 하나 — 화면이 이름을 보이고 갈 곳을 정할 수 있게. */ export interface ExternalRef { id: string /** 사람이 읽는 이름. 없으면 화면이 id 를 보인다(지어내지 않는다). */ name?: string /** * 그 제품에서 이것을 열 수 있는 자리 — 없으면 화면이 링크를 만들지 않는다. * * 막다른 링크를 만들지 않는 것이 이 칸의 요점이다. 눌러서 아무 일도 안 일어나면 사람은 그것을 * 결함으로 읽는다. */ href?: string /** 그 id 가 실제로 있나 — **없으면 없다고 말한다.** 답에서 빼지 않는다. */ missing?: boolean } /** * 밖에서 이 대상을 가리키는 것들 — 들어오는 관계 한 묶음. * * `tense` 를 **제품이 선언한다**(계약의 `RelationTense`). 실적·상태 구간은 지난 일이고 정비 계획· * 지시는 앞의 일이다 — 「걸린 것이 열」이 두 뜻이 되는 자리이므로 묶음이 스스로 말해야 한다. */ export interface ExternalIncoming { /** 관계의 이름(i18n 키) — 계약의 `via` 와 같은 규칙. 제품은 자기 접두를 쓴다. */ via: string /** 어디서 오나 — 사람이 읽는 축 이름이 아니라 그 제품의 축 키다. */ from: string /** 언제의 일인가. 안 주면 「선언이 말하지 않았다」다 — `standing` 으로 바꾸지 않는다. */ tense?: 'past' | 'future' | 'standing' ids: string[] /** 전체 수 — `ids` 가 잘렸어도 몇인지 말한다. */ total: number } /** * 한 external 대상을 푸는 제품 쪽 구현. * * 둘 다 선택이다. 이름만 대 줄 수 있는 제품과 들어오는 관계만 낼 수 있는 제품이 다 있다 — 하나만 * 꽂아도 그만큼은 답해진다. */ /** What the relation being resolved belongs to. Required — a resolver that answers without it answers for the wrong twin. */ export interface ExternalResolveContext { instanceId: string } export interface ExternalResolver { /** 계약의 `target.entity` 와 **같은 낱말**. 다르면 아무 관계에도 안 붙는다. */ entity: string /** * 이 id 들이 무엇인가 — **어느 트윈의 관계인지와 함께** 묻는다. * * 인스턴스를 넘기는 이유(2026-09-18): 밖의 id 는 도메인에서 유일하지 않다. 구역 id(`areaId`)는 * 공간 안에서만 유일하므로, 도메인만으로 찾으면 다른 공간의 같은 id 가 답이 되어 **남의 공간 이름**이 * 이 자리의 부모로 보인다. 부르는 쪽은 인스턴스를 알고 있다. */ resolve?(domainId: string, ids: readonly string[], context: ExternalResolveContext): Promise /** 이 대상을 가리키는 것들. */ incoming?(domainId: string, instanceId: string, axis: string, itemId: string): Promise } const resolvers = new Map() /** * 해소기를 꽂는다 — **부팅에서 한 번.** * * 같은 `entity` 를 두 번 꽂으면 뒤엣것이 이긴다. 그것이 개발 중 재적재의 정상 동작이고, 운영에서 * 일어나면 두 제품이 같은 대상을 자기 것이라 말하는 것이므로 **경고를 남긴다** — 조용히 덮으면 * 어느 쪽이 답하고 있는지 아무도 모른다. */ export function registerExternalResolver(r: ExternalResolver, warn?: (msg: string) => void): void { const entity = String(r?.entity ?? '').trim() if (!entity) { warn?.('[external-resolver] entity 없는 해소기는 꽂지 않는다 — 아무 관계에도 안 붙는다') return } if (resolvers.has(entity)) { warn?.(`[external-resolver] '${entity}' 해소기를 덮어쓴다 — 두 제품이 같은 대상을 답하려 하면 그중 하나만 답한다`) } resolvers.set(entity, { ...r, entity }) } /** 지금 꽂혀 있는 것들 — 「무엇을 풀 수 있나」를 밖에서 잴 수 있게. */ export function externalResolverEntities(): string[] { return [...resolvers.keys()].sort() } /** 시험이 재기동을 넣을 수 있게. */ export function clearExternalResolvers(): void { resolvers.clear() } /** * 그 id 들을 푼다 — **못 풀면 못 푼다고 말한다.** * * 해소기가 터져도 360 전체를 죽이지 않는다. 한 관계가 답을 못 하는 것과 화면이 안 뜨는 것은 다른 * 사고이고, 뒤엣것이 훨씬 나쁘다. */ export async function resolveExternal( entity: string, domainId: string, ids: readonly string[], context: ExternalResolveContext, warn?: (msg: string) => void ): Promise<{ refs?: ExternalRef[]; unresolved?: 'no-resolver' | 'failed' }> { const r = resolvers.get(entity) if (!r?.resolve) return { unresolved: 'no-resolver' } try { return { refs: await r.resolve(domainId, ids, context) } } catch (e: any) { warn?.(`[external-resolver] '${entity}' 해소가 실패했다 — ${String(e?.message ?? e)}`) return { unresolved: 'failed' } } } /** * 밖에서 이 대상을 가리키는 것들을 모은다. * * 꽂힌 것이 없으면 **빈 배열이지 오류가 아니다** — 밖에서 가리키는 것이 없는 대상이 정상이다. * 다만 「해소기가 없어서 모른다」와 「없다」는 다르므로, 그 구별은 `externalResolverEntities()` 로 * 밖에서 잰다. */ export async function externalIncoming( domainId: string, instanceId: string, axis: string, itemId: string, warn?: (msg: string) => void ): Promise { const out: ExternalIncoming[] = [] for (const r of resolvers.values()) { if (!r.incoming) continue try { const got = await r.incoming(domainId, instanceId, axis, itemId) /* 빈 묶음은 싣지 않는다 — 여기서는 「그 제품이 이 축을 안 본다」는 뜻이고 할 말이 없다. */ for (const g of got ?? []) if (g && g.total > 0) out.push(g) } catch (e: any) { warn?.(`[external-resolver] '${r.entity}' 의 들어오는 관계 조회가 실패했다 — ${String(e?.message ?? e)}`) } } return out }