/* * 인제스트가 어느 공간으로 들어갈지 정한다 — **순수**. * * ── 왜 있나 (2026-08-05) ──────────────────────────────────────────────────── * 한 현실(공장·물류센터)을 여러 렌즈가 비춘다: 창고(WMS)·공정(MES)·야드(YMS) 가 같은 부지에 있다. * 엔진은 이미 그것을 지원한다 — 같은 `spaceId` 로 들어오면 공간 content 를 병합하고 기존 값을 이긴다. * * 그런데 **만드는 흐름에 그 선택이 없었다.** 템플릿은 `space.spaceId` 를 정하지 않으므로 항상 * `-site` 로 파생돼, 새 트윈은 늘 새 현장처럼 생겼다. 데이터로는 가능한 일이 저작으로는 * 불가능했다. * * ── 없는 공간에 알리지 않고 새 공간을 만들지 않는다 ──────────────────────────────── * "이 현장에 더한다" 는 요청이 오타·낡은 목록으로 빗나갔을 때 새 공간을 만들어 주면, 사용자는 * 합쳤다고 믿고 화면에서는 따로 놀게 된다 — 나중에 두 현장을 손으로 합쳐야 하고, 그때는 이미 * 이벤트가 양쪽에 쌓여 있다. 그래서 **모르는 공간이면 거절한다.** * * ── 같은 id 를 쓰는 구역은 알리지 않고 합쳐지므로 알린다 ────────────────────────── * 공간 content 의 구역·랜드마크는 id 로 합집합을 만든다(`unionById`). 두 렌즈가 우연히 같은 구역 * id(`a-yard` 따위)를 쓰면 서로 다른 구역이 하나로 합쳐진다. 막을 일은 아니다(같은 현장의 같은 구역을 * 가리키는 정당한 경우가 많다) — 다만 **모르고 지나가지 않게** 경고로 돌려준다. */ /** 공간 판정 결과. */ export interface IngestSpaceChoice { /** 실제로 쓸 공간 id. */ spaceId: string /** 사용자가 기존 현장에 합치기를 골랐나(마스터가 파생한 공간과 다른 곳으로 들어가나). */ joined: boolean } /** 거절 사유 — 호출측이 문장을 만들 때 쓴다(언어 중립 코드). */ export const INGEST_SPACE_NOT_FOUND = 'space-not-found' /** * 요청받은 공간과 마스터가 파생한 공간 중 무엇으로 들어갈지 정한다. * * @param masterSpaceId 마스터에서 파생된 공간 id(요청이 없을 때의 기본). * @param requested 사용자가 고른 기존 공간 id. 없으면 빈 값/undefined. * @param requestedExists 그 공간이 이 테넌트에 실제로 있나(호출측이 조회해 넘긴다). * @throws 요청한 공간이 없으면 — 알리지 않고 새로 만들지 않는다. */ export function resolveIngestSpace(masterSpaceId: string, requested: string | undefined | null, requestedExists: boolean): IngestSpaceChoice { const want = (requested ?? '').trim() if (!want) return { spaceId: masterSpaceId, joined: false } if (!requestedExists) { const e = new Error(`space "${want}" not found — cannot co-locate into a space that does not exist`) ;(e as any).code = INGEST_SPACE_NOT_FOUND throw e } /* 마스터가 이미 그 공간을 가리키면 합치기가 아니다 — 같은 공간의 재인제스트다. */ return { spaceId: want, joined: want !== masterSpaceId } } /** * 실 시스템 import 가 현장을 고를 때의 거절 판정 — **순수**. * * 레퍼런스 하나는 사이트를 여럿 발견할 수 있고(`discoverSites`), 그 사이트마다 트윈이 하나씩 생긴다. * 그런데 "이 현장에 더한다" 는 **한 곳**을 가리키는 말이다. 사이트 여럿을 한 공간에 넣으면 서로 다른 * 현장의 구역·랜드마크가 id 로 합쳐지고(`unionById`), 그 사실을 알아차릴 때는 이미 이벤트가 양쪽에 * 쌓여 있다 — 손으로 되돌릴 수 없는 종류다. 그래서 **묻지 않고 합치지 않고, 거절한다.** * * 공간을 고르지 않으면 지금까지의 거동 그대로다: 사이트마다 마스터가 파생한 공간으로 들어간다. */ export type ImportSpaceRefusalCode = 'space-with-multiple-sites' export interface ImportSpaceRefusal { code: ImportSpaceRefusalCode params?: Record /** 영어 canonical — 로그에 남고, 번역이 없으면 그대로 보인다(발견 오류와 같은 규약). */ message: string } /** * @param siteCount 실제로 만들 사이트 수(필터 적용 후). * @param requested 사용자가 고른 기존 공간 id. 없으면 판정할 것이 없다. * @returns 거절 사유. 없으면 진행해도 된다. */ export function refuseImportSpace(siteCount: number, requested?: string | null): ImportSpaceRefusal | undefined { const want = (requested ?? '').trim() if (!want || siteCount <= 1) return undefined return { code: 'space-with-multiple-sites', params: { spaceId: want, count: siteCount }, message: `cannot co-locate ${siteCount} sites into space "${want}" — pick one site, or omit the space so each site gets its own` } } /** 구역·랜드마크처럼 id 로 합쳐지는 것들. */ interface Identified { id?: string } /** * 합치는 쪽과 기존 쪽이 같은 id 를 쓰는 것들을 찾아 경고 문장을 만든다. * * 정당한 경우(같은 현장의 같은 구역)와 사고(서로 다른 구역이 우연히 같은 이름)를 기계가 가릴 수는 * 없다. 구분할 수 없는 것을 고르지 않고, **무엇이 합쳐졌는지 말한다.** */ export function collidingIds(existing: readonly Identified[] | undefined | null, incoming: readonly Identified[] | undefined | null): string[] { const have = new Set((existing ?? []).map(x => x?.id).filter(Boolean) as string[]) const hits = new Set() for (const x of incoming ?? []) if (x?.id && have.has(x.id)) hits.add(x.id) return [...hits].sort() }