@things-factory/headless-twin 10.1.27 → 10.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ const index_js_2 = require("./engine/index.js");
13
13
  const reference_live_js_1 = require("./service/reference/reference-live.js");
14
14
  const index_js_3 = require("./service/twin-ingest-window/index.js");
15
15
  const reference_links_js_1 = require("./service/reference/reference-links.js");
16
+ const area_resolver_js_1 = require("./service/twin-space/area-resolver.js");
16
17
  const rule_loop_js_1 = require("./service/actuation/rule-loop.js");
17
18
  const fill_loop_js_1 = require("./service/reference/fill-loop.js");
18
19
  /*
@@ -36,6 +37,11 @@ process.on('bootstrap-module-start', async ({ app, config, client }) => {
36
37
  * 것들. 조회용 참조는 트윈으로 복제하지 않고 그때 물어본다.
37
38
  */
38
39
  (0, reference_links_js_1.installReferenceLinkResolver)();
40
+ /*
41
+ * 구역 이름을 답하는 문 — 자리의 부모가 구역일 때 화면이 id 대신 이름을 보인다. 등록이 없던
42
+ * 동안에는 모든 자리의 부모가 「풀 수 있는 부품이 없다」로 나왔다(2026-09-18 실측).
43
+ */
44
+ (0, area_resolver_js_1.installAreaResolver)(m => (0, log_js_1.twinLog)(m));
39
45
  /*
40
46
  * **저널 보존 기간은 선언에서 온다** — 코드에 박아 두지 않는다(2026-08-22).
41
47
  *
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;;AAAA,4DAAiC;AACjC,6DAAkC;AAClC,qEAAqE;AACrE,kDAAkD;AAAzC,sGAAA,UAAU,OAAA;AAEnB,4CAAoD;AACpD,uBAAoB;AAEpB,gDAA8C;AAC9C,6EAAgF;AAChF,oEAAiF;AACjF,+EAAqF;AACrF,mEAAyE;AACzE,mEAAgE;AAEhE;;;;;;;;GAQG;AACH,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAO,EAAE,EAAE;IACjF,IAAI,CAAC;QACH;;;WAGG;QACH,IAAA,oCAAyB,GAAE,CAAA;QAC3B;;;WAGG;QACH,IAAA,iDAA4B,GAAE,CAAA;QAC9B;;;;;;;;WAQG;QACH;;;;;;;;WAQG;QACH,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,2BAA2B,IAAI,CAAC,CAAC,CAAA;QACpE,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,oBAAoB,IAAI,CAAC,CAAC,CAAA;QAC3E,MAAM,aAAa,GAAG,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAA;QACxD,IAAI,aAAa,GAAG,CAAC,EAAE,CAAC;YACtB,qBAAU,CAAC,sBAAsB,GAAG,aAAa,CAAA;YACjD,IAAA,gBAAO,EAAC,4BAA4B,aAAa,MAAM,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAA;QACxF,CAAC;QAED,MAAM,qBAAU,CAAC,SAAS,EAAE,CAAA;QAC5B,MAAM,IAAA,4CAAwB,GAAE,CAAA;QAChC;;;WAGG;QACH,IAAA,4BAAa,GAAE,CAAA;QAEf;;;;;;WAMG;QACH,IAAA,qCAAsB,GAAE,CAAA;QAExB,IAAA,gBAAO,EAAC,iCAAiC,CAAC,CAAA;IAC5C,CAAC;IAAC,OAAO,EAAE,EAAE,CAAC;QACZ,IAAA,kBAAS,EAAC,qCAAqC,EAAE,EAAE,CAAC,CAAA;IACtD,CAAC;AACH,CAAC,CAAC,CAAA","sourcesContent":["export * from './engine/index.js'\nexport * from './service/index.js'\n/* 스키마 이력 — 프레임워크가 `migrations` export 를 모아 실행한다(`run migration`). */\nexport { migrations } from './migrations/index.js'\n\nimport { twinLog, twinError } from './engine/log.js'\nimport './routes.js'\n\nimport { TwinEngine } from './engine/index.js'\nimport { resumeReferenceLiveFeeds } from './service/reference/reference-live.js'\nimport { installIngestWindowWriter } from './service/twin-ingest-window/index.js'\nimport { installReferenceLinkResolver } from './service/reference/reference-links.js'\nimport { startActuationRuleLoop } from './service/actuation/rule-loop.js'\nimport { startFillLoop } from './service/reference/fill-loop.js'\n\n/*\n * 모듈 부팅 — **도는 트윈을 되살린다.**\n *\n * 두 층이 순서대로다: ① 엔진이 등록부의 `running` 행을 선언된 `restartPolicy` 그대로 되살리고\n * (`bootstrap`), ② 레퍼런스 계층이 미러 트윈의 커넥터 피드를 다시 붙인다. 순서가 뒤집히면 피드가\n * 아직 없는 커널에 계측을 붓게 된다.\n *\n * 예전에는 ①도 ②도 없었다 — 재기동 한 번에 등록부만 「도는 중」이라 말하는 트윈이 남았다.\n */\nprocess.on('bootstrap-module-start' as any, async ({ app, config, client }: any) => {\n try {\n /*\n * 닫힌 인제스트 창을 표로 옮기는 문을 먼저 연다 — 트윈을 되살리기 **전에** 등록해야 첫 창부터\n * 남는다. 커널 층은 저장 계층을 모르므로 이 등록이 그 둘을 잇는 유일한 자리다.\n */\n installIngestWindowWriter()\n /*\n * 원본에만 있는 관계를 조회하는 문을 연다 — MES 의 생산 실적·정비 계획처럼 트윈이 담지 않는\n * 것들. 조회용 참조는 트윈으로 복제하지 않고 그때 물어본다.\n */\n installReferenceLinkResolver()\n /*\n * **저널 보존 기간은 선언에서 온다** — 코드에 박아 두지 않는다(2026-08-22).\n *\n * 실측: 개발 저널이 시간당 581,794행 · 0.85GB(하루 20GB) 로 자랐고 지우는 것이 없었다. 그런데\n * 「며칠까지 남기나」는 **배포마다 갈리는 사실**이다 — 개발은 짧아도 되고 실증은 길어야 한다.\n *\n * 선언이 없으면 **아무것도 지우지 않는다.** 알리지 않고 지우는 편이 알리지 않고 쌓는 것보다 나쁘다.\n * 값은 스냅샷 TTL(7일)보다 짧을 수 없다 — 엔진이 그것을 올려 주고 그 사실을 말한다.\n */\n /*\n * 읽는 순서: **환경변수 → 설정 → 없음.**\n *\n * 설정(`config.twin.journalRetentionDays`)이 배포의 선언이고, 환경변수\n * (`TWIN_JOURNAL_RETENTION_DAYS`)는 그것을 **그 자리에서** 덮는다. 컨테이너로 도는 배포에서는\n * 파일을 고치지 않고 값을 바꿀 수 있어야 하고, 개발에서도 그 편이 빠르다.\n *\n * 둘 다 없으면 **아무것도 지우지 않는다.**\n */\n const fromEnv = Number(process.env.TWIN_JOURNAL_RETENTION_DAYS ?? 0)\n const fromConfig = Number(config?.get?.('twin')?.journalRetentionDays ?? 0)\n const retentionDays = fromEnv > 0 ? fromEnv : fromConfig\n if (retentionDays > 0) {\n TwinEngine.JOURNAL_RETENTION_DAYS = retentionDays\n twinLog(`[twin] journal retention ${retentionDays}일 (${fromEnv > 0 ? '환경변수' : '설정'})`)\n }\n\n await TwinEngine.bootstrap()\n await resumeReferenceLiveFeeds()\n /*\n * 빠진 구간을 다시 받는 주기 — 기동에서 한 번, 그 뒤 10분마다(§`fill-loop.ts`).\n * 피드를 붙인 **뒤에** 세운다: 커널이 되살아나기 전에 부르면 도는 트윈이 0개다.\n */\n startFillLoop()\n\n /*\n * 조치 규칙 주기 — **트윈이 다 선 뒤에** 세운다. 앞에 세우면 첫 바퀴가 아직 안 선 트윈들을 돌고\n * 「상태를 읽을 수 없다」를 규칙 행에 적는다. 그것은 사실이지만 사람에게는 오해다.\n *\n * 커널 틱과 따로 돈다 — 틱은 예외가 나면 그 트윈을 멈추고, 규칙 하나가 잘못됐다고 공장이 멈추면\n * 안 된다(§`rule-loop.ts`).\n */\n startActuationRuleLoop()\n\n twinLog('Headless Twin host has started.')\n } catch (ex) {\n twinError('Headless Twin host failed to start.', ex)\n }\n})\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;;AAAA,4DAAiC;AACjC,6DAAkC;AAClC,qEAAqE;AACrE,kDAAkD;AAAzC,sGAAA,UAAU,OAAA;AAEnB,4CAAoD;AACpD,uBAAoB;AAEpB,gDAA8C;AAC9C,6EAAgF;AAChF,oEAAiF;AACjF,+EAAqF;AACrF,4EAA2E;AAC3E,mEAAyE;AACzE,mEAAgE;AAEhE;;;;;;;;GAQG;AACH,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAO,EAAE,EAAE;IACjF,IAAI,CAAC;QACH;;;WAGG;QACH,IAAA,oCAAyB,GAAE,CAAA;QAC3B;;;WAGG;QACH,IAAA,iDAA4B,GAAE,CAAA;QAC9B;;;WAGG;QACH,IAAA,sCAAmB,EAAC,CAAC,CAAC,EAAE,CAAC,IAAA,gBAAO,EAAC,CAAC,CAAC,CAAC,CAAA;QACpC;;;;;;;;WAQG;QACH;;;;;;;;WAQG;QACH,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,2BAA2B,IAAI,CAAC,CAAC,CAAA;QACpE,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,oBAAoB,IAAI,CAAC,CAAC,CAAA;QAC3E,MAAM,aAAa,GAAG,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAA;QACxD,IAAI,aAAa,GAAG,CAAC,EAAE,CAAC;YACtB,qBAAU,CAAC,sBAAsB,GAAG,aAAa,CAAA;YACjD,IAAA,gBAAO,EAAC,4BAA4B,aAAa,MAAM,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAA;QACxF,CAAC;QAED,MAAM,qBAAU,CAAC,SAAS,EAAE,CAAA;QAC5B,MAAM,IAAA,4CAAwB,GAAE,CAAA;QAChC;;;WAGG;QACH,IAAA,4BAAa,GAAE,CAAA;QAEf;;;;;;WAMG;QACH,IAAA,qCAAsB,GAAE,CAAA;QAExB,IAAA,gBAAO,EAAC,iCAAiC,CAAC,CAAA;IAC5C,CAAC;IAAC,OAAO,EAAE,EAAE,CAAC;QACZ,IAAA,kBAAS,EAAC,qCAAqC,EAAE,EAAE,CAAC,CAAA;IACtD,CAAC;AACH,CAAC,CAAC,CAAA","sourcesContent":["export * from './engine/index.js'\nexport * from './service/index.js'\n/* 스키마 이력 — 프레임워크가 `migrations` export 를 모아 실행한다(`run migration`). */\nexport { migrations } from './migrations/index.js'\n\nimport { twinLog, twinError } from './engine/log.js'\nimport './routes.js'\n\nimport { TwinEngine } from './engine/index.js'\nimport { resumeReferenceLiveFeeds } from './service/reference/reference-live.js'\nimport { installIngestWindowWriter } from './service/twin-ingest-window/index.js'\nimport { installReferenceLinkResolver } from './service/reference/reference-links.js'\nimport { installAreaResolver } from './service/twin-space/area-resolver.js'\nimport { startActuationRuleLoop } from './service/actuation/rule-loop.js'\nimport { startFillLoop } from './service/reference/fill-loop.js'\n\n/*\n * 모듈 부팅 — **도는 트윈을 되살린다.**\n *\n * 두 층이 순서대로다: ① 엔진이 등록부의 `running` 행을 선언된 `restartPolicy` 그대로 되살리고\n * (`bootstrap`), ② 레퍼런스 계층이 미러 트윈의 커넥터 피드를 다시 붙인다. 순서가 뒤집히면 피드가\n * 아직 없는 커널에 계측을 붓게 된다.\n *\n * 예전에는 ①도 ②도 없었다 — 재기동 한 번에 등록부만 「도는 중」이라 말하는 트윈이 남았다.\n */\nprocess.on('bootstrap-module-start' as any, async ({ app, config, client }: any) => {\n try {\n /*\n * 닫힌 인제스트 창을 표로 옮기는 문을 먼저 연다 — 트윈을 되살리기 **전에** 등록해야 첫 창부터\n * 남는다. 커널 층은 저장 계층을 모르므로 이 등록이 그 둘을 잇는 유일한 자리다.\n */\n installIngestWindowWriter()\n /*\n * 원본에만 있는 관계를 조회하는 문을 연다 — MES 의 생산 실적·정비 계획처럼 트윈이 담지 않는\n * 것들. 조회용 참조는 트윈으로 복제하지 않고 그때 물어본다.\n */\n installReferenceLinkResolver()\n /*\n * 구역 이름을 답하는 문 — 자리의 부모가 구역일 때 화면이 id 대신 이름을 보인다. 등록이 없던\n * 동안에는 모든 자리의 부모가 「풀 수 있는 부품이 없다」로 나왔다(2026-09-18 실측).\n */\n installAreaResolver(m => twinLog(m))\n /*\n * **저널 보존 기간은 선언에서 온다** — 코드에 박아 두지 않는다(2026-08-22).\n *\n * 실측: 개발 저널이 시간당 581,794행 · 0.85GB(하루 20GB) 로 자랐고 지우는 것이 없었다. 그런데\n * 「며칠까지 남기나」는 **배포마다 갈리는 사실**이다 — 개발은 짧아도 되고 실증은 길어야 한다.\n *\n * 선언이 없으면 **아무것도 지우지 않는다.** 알리지 않고 지우는 편이 알리지 않고 쌓는 것보다 나쁘다.\n * 값은 스냅샷 TTL(7일)보다 짧을 수 없다 — 엔진이 그것을 올려 주고 그 사실을 말한다.\n */\n /*\n * 읽는 순서: **환경변수 → 설정 → 없음.**\n *\n * 설정(`config.twin.journalRetentionDays`)이 배포의 선언이고, 환경변수\n * (`TWIN_JOURNAL_RETENTION_DAYS`)는 그것을 **그 자리에서** 덮는다. 컨테이너로 도는 배포에서는\n * 파일을 고치지 않고 값을 바꿀 수 있어야 하고, 개발에서도 그 편이 빠르다.\n *\n * 둘 다 없으면 **아무것도 지우지 않는다.**\n */\n const fromEnv = Number(process.env.TWIN_JOURNAL_RETENTION_DAYS ?? 0)\n const fromConfig = Number(config?.get?.('twin')?.journalRetentionDays ?? 0)\n const retentionDays = fromEnv > 0 ? fromEnv : fromConfig\n if (retentionDays > 0) {\n TwinEngine.JOURNAL_RETENTION_DAYS = retentionDays\n twinLog(`[twin] journal retention ${retentionDays}일 (${fromEnv > 0 ? '환경변수' : '설정'})`)\n }\n\n await TwinEngine.bootstrap()\n await resumeReferenceLiveFeeds()\n /*\n * 빠진 구간을 다시 받는 주기 — 기동에서 한 번, 그 뒤 10분마다(§`fill-loop.ts`).\n * 피드를 붙인 **뒤에** 세운다: 커널이 되살아나기 전에 부르면 도는 트윈이 0개다.\n */\n startFillLoop()\n\n /*\n * 조치 규칙 주기 — **트윈이 다 선 뒤에** 세운다. 앞에 세우면 첫 바퀴가 아직 안 선 트윈들을 돌고\n * 「상태를 읽을 수 없다」를 규칙 행에 적는다. 그것은 사실이지만 사람에게는 오해다.\n *\n * 커널 틱과 따로 돈다 — 틱은 예외가 나면 그 트윈을 멈추고, 규칙 하나가 잘못됐다고 공장이 멈추면\n * 안 된다(§`rule-loop.ts`).\n */\n startActuationRuleLoop()\n\n twinLog('Headless Twin host has started.')\n } catch (ex) {\n twinError('Headless Twin host failed to start.', ex)\n }\n})\n"]}
@@ -36,11 +36,21 @@ export interface ExternalIncoming {
36
36
  * 둘 다 선택이다. 이름만 대 줄 수 있는 제품과 들어오는 관계만 낼 수 있는 제품이 다 있다 — 하나만
37
37
  * 꽂아도 그만큼은 답해진다.
38
38
  */
39
+ /** What the relation being resolved belongs to. Required — a resolver that answers without it answers for the wrong twin. */
40
+ export interface ExternalResolveContext {
41
+ instanceId: string;
42
+ }
39
43
  export interface ExternalResolver {
40
44
  /** 계약의 `target.entity` 와 **같은 낱말**. 다르면 아무 관계에도 안 붙는다. */
41
45
  entity: string;
42
- /** 이 id 들이 무엇인가. */
43
- resolve?(domainId: string, ids: readonly string[]): Promise<ExternalRef[]>;
46
+ /**
47
+ * 이 id 들이 무엇인가 — **어느 트윈의 관계인지와 함께** 묻는다.
48
+ *
49
+ * 인스턴스를 넘기는 이유(2026-09-18): 밖의 id 는 도메인에서 유일하지 않다. 구역 id(`areaId`)는
50
+ * 공간 안에서만 유일하므로, 도메인만으로 찾으면 다른 공간의 같은 id 가 답이 되어 **남의 공간 이름**이
51
+ * 이 자리의 부모로 보인다. 부르는 쪽은 인스턴스를 알고 있다.
52
+ */
53
+ resolve?(domainId: string, ids: readonly string[], context: ExternalResolveContext): Promise<ExternalRef[]>;
44
54
  /** 이 대상을 가리키는 것들. */
45
55
  incoming?(domainId: string, instanceId: string, axis: string, itemId: string): Promise<ExternalIncoming[]>;
46
56
  }
@@ -62,7 +72,7 @@ export declare function clearExternalResolvers(): void;
62
72
  * 해소기가 터져도 360 전체를 죽이지 않는다. 한 관계가 답을 못 하는 것과 화면이 안 뜨는 것은 다른
63
73
  * 사고이고, 뒤엣것이 훨씬 나쁘다.
64
74
  */
65
- export declare function resolveExternal(entity: string, domainId: string, ids: readonly string[], warn?: (msg: string) => void): Promise<{
75
+ export declare function resolveExternal(entity: string, domainId: string, ids: readonly string[], context: ExternalResolveContext, warn?: (msg: string) => void): Promise<{
66
76
  refs?: ExternalRef[];
67
77
  unresolved?: 'no-resolver' | 'failed';
68
78
  }>;
@@ -69,12 +69,12 @@ function clearExternalResolvers() {
69
69
  * 해소기가 터져도 360 전체를 죽이지 않는다. 한 관계가 답을 못 하는 것과 화면이 안 뜨는 것은 다른
70
70
  * 사고이고, 뒤엣것이 훨씬 나쁘다.
71
71
  */
72
- async function resolveExternal(entity, domainId, ids, warn) {
72
+ async function resolveExternal(entity, domainId, ids, context, warn) {
73
73
  const r = resolvers.get(entity);
74
74
  if (!r?.resolve)
75
75
  return { unresolved: 'no-resolver' };
76
76
  try {
77
- return { refs: await r.resolve(domainId, ids) };
77
+ return { refs: await r.resolve(domainId, ids, context) };
78
78
  }
79
79
  catch (e) {
80
80
  warn?.(`[external-resolver] '${entity}' 해소가 실패했다 — ${String(e?.message ?? e)}`);
@@ -1 +1 @@
1
- {"version":3,"file":"external-resolver.js","sourceRoot":"","sources":["../../../server/service/twin-model/external-resolver.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;;AA4DH,4DAUC;AAGD,4DAEC;AAGD,wDAEC;AAQD,0CAcC;AASD,4CAmBC;AA/ED,MAAM,SAAS,GAAG,IAAI,GAAG,EAA4B,CAAA;AAErD;;;;;;GAMG;AACH,SAAgB,wBAAwB,CAAC,CAAmB,EAAE,IAA4B;IACxF,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IAC7C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,IAAI,EAAE,CAAC,2DAA2D,CAAC,CAAA;QACnE,OAAM;IACR,CAAC;IACD,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1B,IAAI,EAAE,CAAC,wBAAwB,MAAM,8CAA8C,CAAC,CAAA;IACtF,CAAC;IACD,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC,CAAA;AACzC,CAAC;AAED,8CAA8C;AAC9C,SAAgB,wBAAwB;IACtC,OAAO,CAAC,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;AACrC,CAAC;AAED,wBAAwB;AACxB,SAAgB,sBAAsB;IACpC,SAAS,CAAC,KAAK,EAAE,CAAA;AACnB,CAAC;AAED;;;;;GAKG;AACI,KAAK,UAAU,eAAe,CACnC,MAAc,EACd,QAAgB,EAChB,GAAsB,EACtB,IAA4B;IAE5B,MAAM,CAAC,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IAC/B,IAAI,CAAC,CAAC,EAAE,OAAO;QAAE,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,CAAA;IACrD,IAAI,CAAC;QACH,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAA;IACjD,CAAC;IAAC,OAAO,CAAM,EAAE,CAAC;QAChB,IAAI,EAAE,CAAC,wBAAwB,MAAM,gBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,IAAI,CAAC,CAAC,EAAE,CAAC,CAAA;QAC/E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAA;IACjC,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,gBAAgB,CACpC,QAAgB,EAChB,UAAkB,EAClB,IAAY,EACZ,MAAc,EACd,IAA4B;IAE5B,MAAM,GAAG,GAAuB,EAAE,CAAA;IAClC,KAAK,MAAM,CAAC,IAAI,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC;QACnC,IAAI,CAAC,CAAC,CAAC,QAAQ;YAAE,SAAQ;QACzB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,QAAQ,CAAC,QAAQ,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,CAAC,CAAA;YAChE,yDAAyD;YACzD,KAAK,MAAM,CAAC,IAAI,GAAG,IAAI,EAAE;gBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC;oBAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAC9D,CAAC;QAAC,OAAO,CAAM,EAAE,CAAC;YAChB,IAAI,EAAE,CAAC,wBAAwB,CAAC,CAAC,MAAM,0BAA0B,MAAM,CAAC,CAAC,EAAE,OAAO,IAAI,CAAC,CAAC,EAAE,CAAC,CAAA;QAC7F,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["/*\n * **board 밖을 가리키는 관계를 푸는 자리** — 선언은 있고 코드가 0곳이던 이음새.\n *\n * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────\n * 계약이 `target: { kind: 'external', entity: … }` 를 선언하고 주석에 「해소는 호스트가 한다」고\n * 적어 두었다. 그런데 **`headless-twin/server` 어디에도 `kind === 'external'` 을 다루는 코드가\n * 없었다**(0곳).\n *\n * `space.area` 가 도는 것처럼 보인 이유는 이음새가 있어서가 아니라 **호스트에서 그것이 평범한 FK\n * 라서**다. TypeORM 메타데이터가 우연히 답해 준 것이지 선언이 길을 낸 것이 아니다.\n *\n * ── 왜 지금 채우나 ─────────────────────────────────────────────────────────\n * 아침에는 「아무도 안 막혔으니 급하지 않다」로 적었다. **그 판단이 규모를 작게 잡은 것이었다.**\n *\n * 360(한 대상에 걸린 것을 한자리에)의 소비처는 무한히 늘어난다. 그러면 각 제품의 자기 저장소가\n * 관계의 원천이 된다 — MES 의 실적·상태 구간·정비 계획이 그렇고, 다음 제품도 그럴 것이다.\n *\n * 그때 **선언은 「밖을 가리킨다」고 말하는데 푸는 길이 없으면 제품마다 자기 길을 낸다.** 그것이\n * 정확히 「무한히 갈린다」다. 이 파일이 그 갈림을 막는다.\n *\n * ── 커널은 어느 표인지 모른다 ──────────────────────────────────────────────\n * 계약은 「이 축이 밖을 가리킨다」까지만 안다. **「어느 표의 어느 칸」은 제품이 안다.** 그래서 제품이\n * 부팅에서 해소기를 꽂는다 — 장부 기록기(`installIngestWindowWriter`)와 같은 규율이다.\n *\n * 커널이 표 이름을 알게 되면 한 제품의 저장 구조에 매이고, 다음 제품이 다른 표를 쓰면 커널을 또\n * 고쳐야 한다.\n *\n * ── 없는 것을 숨기지 않는다 ────────────────────────────────────────────────\n * 해소기가 없으면 **그 사실을 낸다**(`unresolved`). 링크를 지우면 화면은 「그런 관계가 없다」로\n * 읽고, 실제로는 「아무도 답할 수 없다」다. 그 둘은 사람이 할 일이 다르다.\n */\n\n/** 밖에 사는 대상 하나 — 화면이 이름을 보이고 갈 곳을 정할 수 있게. */\nexport interface ExternalRef {\n id: string\n /** 사람이 읽는 이름. 없으면 화면이 id 를 보인다(지어내지 않는다). */\n name?: string\n /**\n * 그 제품에서 이것을 열 수 있는 자리 — 없으면 화면이 링크를 만들지 않는다.\n *\n * 막다른 링크를 만들지 않는 것이 이 칸의 요점이다. 눌러서 아무 일도 안 일어나면 사람은 그것을\n * 결함으로 읽는다.\n */\n href?: string\n /** 그 id 가 실제로 있나 — **없으면 없다고 말한다.** 답에서 빼지 않는다. */\n missing?: boolean\n}\n\n/**\n * 밖에서 이 대상을 가리키는 것들 — 들어오는 관계 한 묶음.\n *\n * `tense` 를 **제품이 선언한다**(계약의 `RelationTense`). 실적·상태 구간은 지난 일이고 정비 계획·\n * 지시는 앞의 일이다 — 「걸린 것이 열」이 두 뜻이 되는 자리이므로 묶음이 스스로 말해야 한다.\n */\nexport interface ExternalIncoming {\n /** 관계의 이름(i18n 키) — 계약의 `via` 와 같은 규칙. 제품은 자기 접두를 쓴다. */\n via: string\n /** 어디서 오나 — 사람이 읽는 축 이름이 아니라 그 제품의 축 키다. */\n from: string\n /** 언제의 일인가. 안 주면 「선언이 말하지 않았다」다 — `standing` 으로 바꾸지 않는다. */\n tense?: 'past' | 'future' | 'standing'\n ids: string[]\n /** 전체 수 — `ids` 가 잘렸어도 몇인지 말한다. */\n total: number\n}\n\n/**\n * 한 external 대상을 푸는 제품 쪽 구현.\n *\n * 둘 다 선택이다. 이름만 대 줄 수 있는 제품과 들어오는 관계만 낼 수 있는 제품이 다 있다 — 하나만\n * 꽂아도 그만큼은 답해진다.\n */\nexport interface ExternalResolver {\n /** 계약의 `target.entity` 와 **같은 낱말**. 다르면 아무 관계에도 안 붙는다. */\n entity: string\n /** 이 id 들이 무엇인가. */\n resolve?(domainId: string, ids: readonly string[]): Promise<ExternalRef[]>\n /** 이 대상을 가리키는 것들. */\n incoming?(domainId: string, instanceId: string, axis: string, itemId: string): Promise<ExternalIncoming[]>\n}\n\nconst resolvers = new Map<string, ExternalResolver>()\n\n/**\n * 해소기를 꽂는다 — **부팅에서 한 번.**\n *\n * 같은 `entity` 를 두 번 꽂으면 뒤엣것이 이긴다. 그것이 개발 중 재적재의 정상 동작이고, 운영에서\n * 일어나면 두 제품이 같은 대상을 자기 것이라 말하는 것이므로 **경고를 남긴다** — 조용히 덮으면\n * 어느 쪽이 답하고 있는지 아무도 모른다.\n */\nexport function registerExternalResolver(r: ExternalResolver, warn?: (msg: string) => void): void {\n const entity = String(r?.entity ?? '').trim()\n if (!entity) {\n warn?.('[external-resolver] entity 없는 해소기는 꽂지 않는다 — 아무 관계에도 안 붙는다')\n return\n }\n if (resolvers.has(entity)) {\n warn?.(`[external-resolver] '${entity}' 해소기를 덮어쓴다 — 두 제품이 같은 대상을 답하려 하면 그중 하나만 답한다`)\n }\n resolvers.set(entity, { ...r, entity })\n}\n\n/** 지금 꽂혀 있는 것들 — 「무엇을 풀 수 있나」를 밖에서 잴 수 있게. */\nexport function externalResolverEntities(): string[] {\n return [...resolvers.keys()].sort()\n}\n\n/** 시험이 재기동을 넣을 수 있게. */\nexport function clearExternalResolvers(): void {\n resolvers.clear()\n}\n\n/**\n * 그 id 들을 푼다 — **못 풀면 못 푼다고 말한다.**\n *\n * 해소기가 터져도 360 전체를 죽이지 않는다. 한 관계가 답을 못 하는 것과 화면이 안 뜨는 것은 다른\n * 사고이고, 뒤엣것이 훨씬 나쁘다.\n */\nexport async function resolveExternal(\n entity: string,\n domainId: string,\n ids: readonly string[],\n warn?: (msg: string) => void\n): Promise<{ refs?: ExternalRef[]; unresolved?: 'no-resolver' | 'failed' }> {\n const r = resolvers.get(entity)\n if (!r?.resolve) return { unresolved: 'no-resolver' }\n try {\n return { refs: await r.resolve(domainId, ids) }\n } catch (e: any) {\n warn?.(`[external-resolver] '${entity}' 해소가 실패했다 — ${String(e?.message ?? e)}`)\n return { unresolved: 'failed' }\n }\n}\n\n/**\n * 밖에서 이 대상을 가리키는 것들을 모은다.\n *\n * 꽂힌 것이 없으면 **빈 배열이지 오류가 아니다** — 밖에서 가리키는 것이 없는 대상이 정상이다.\n * 다만 「해소기가 없어서 모른다」와 「없다」는 다르므로, 그 구별은 `externalResolverEntities()` 로\n * 밖에서 잰다.\n */\nexport async function externalIncoming(\n domainId: string,\n instanceId: string,\n axis: string,\n itemId: string,\n warn?: (msg: string) => void\n): Promise<ExternalIncoming[]> {\n const out: ExternalIncoming[] = []\n for (const r of resolvers.values()) {\n if (!r.incoming) continue\n try {\n const got = await r.incoming(domainId, instanceId, axis, itemId)\n /* 빈 묶음은 싣지 않는다 — 여기서는 「그 제품이 이 축을 안 본다」는 뜻이고 할 말이 없다. */\n for (const g of got ?? []) if (g && g.total > 0) out.push(g)\n } catch (e: any) {\n warn?.(`[external-resolver] '${r.entity}' 의 들어오는 관계 조회가 실패했다 — ${String(e?.message ?? e)}`)\n }\n }\n return out\n}\n"]}
1
+ {"version":3,"file":"external-resolver.js","sourceRoot":"","sources":["../../../server/service/twin-model/external-resolver.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;;AAuEH,4DAUC;AAGD,4DAEC;AAGD,wDAEC;AAQD,0CAeC;AASD,4CAmBC;AAhFD,MAAM,SAAS,GAAG,IAAI,GAAG,EAA4B,CAAA;AAErD;;;;;;GAMG;AACH,SAAgB,wBAAwB,CAAC,CAAmB,EAAE,IAA4B;IACxF,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;IAC7C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,IAAI,EAAE,CAAC,2DAA2D,CAAC,CAAA;QACnE,OAAM;IACR,CAAC;IACD,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1B,IAAI,EAAE,CAAC,wBAAwB,MAAM,8CAA8C,CAAC,CAAA;IACtF,CAAC;IACD,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC,CAAA;AACzC,CAAC;AAED,8CAA8C;AAC9C,SAAgB,wBAAwB;IACtC,OAAO,CAAC,GAAG,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;AACrC,CAAC;AAED,wBAAwB;AACxB,SAAgB,sBAAsB;IACpC,SAAS,CAAC,KAAK,EAAE,CAAA;AACnB,CAAC;AAED;;;;;GAKG;AACI,KAAK,UAAU,eAAe,CACnC,MAAc,EACd,QAAgB,EAChB,GAAsB,EACtB,OAA+B,EAC/B,IAA4B;IAE5B,MAAM,CAAC,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IAC/B,IAAI,CAAC,CAAC,EAAE,OAAO;QAAE,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,CAAA;IACrD,IAAI,CAAC;QACH,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,CAAA;IAC1D,CAAC;IAAC,OAAO,CAAM,EAAE,CAAC;QAChB,IAAI,EAAE,CAAC,wBAAwB,MAAM,gBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,IAAI,CAAC,CAAC,EAAE,CAAC,CAAA;QAC/E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAA;IACjC,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,gBAAgB,CACpC,QAAgB,EAChB,UAAkB,EAClB,IAAY,EACZ,MAAc,EACd,IAA4B;IAE5B,MAAM,GAAG,GAAuB,EAAE,CAAA;IAClC,KAAK,MAAM,CAAC,IAAI,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC;QACnC,IAAI,CAAC,CAAC,CAAC,QAAQ;YAAE,SAAQ;QACzB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,QAAQ,CAAC,QAAQ,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,CAAC,CAAA;YAChE,yDAAyD;YACzD,KAAK,MAAM,CAAC,IAAI,GAAG,IAAI,EAAE;gBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC;oBAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAC9D,CAAC;QAAC,OAAO,CAAM,EAAE,CAAC;YAChB,IAAI,EAAE,CAAC,wBAAwB,CAAC,CAAC,MAAM,0BAA0B,MAAM,CAAC,CAAC,EAAE,OAAO,IAAI,CAAC,CAAC,EAAE,CAAC,CAAA;QAC7F,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["/*\n * **board 밖을 가리키는 관계를 푸는 자리** — 선언은 있고 코드가 0곳이던 이음새.\n *\n * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────\n * 계약이 `target: { kind: 'external', entity: … }` 를 선언하고 주석에 「해소는 호스트가 한다」고\n * 적어 두었다. 그런데 **`headless-twin/server` 어디에도 `kind === 'external'` 을 다루는 코드가\n * 없었다**(0곳).\n *\n * `space.area` 가 도는 것처럼 보인 이유는 이음새가 있어서가 아니라 **호스트에서 그것이 평범한 FK\n * 라서**다. TypeORM 메타데이터가 우연히 답해 준 것이지 선언이 길을 낸 것이 아니다.\n *\n * ── 왜 지금 채우나 ─────────────────────────────────────────────────────────\n * 아침에는 「아무도 안 막혔으니 급하지 않다」로 적었다. **그 판단이 규모를 작게 잡은 것이었다.**\n *\n * 360(한 대상에 걸린 것을 한자리에)의 소비처는 무한히 늘어난다. 그러면 각 제품의 자기 저장소가\n * 관계의 원천이 된다 — MES 의 실적·상태 구간·정비 계획이 그렇고, 다음 제품도 그럴 것이다.\n *\n * 그때 **선언은 「밖을 가리킨다」고 말하는데 푸는 길이 없으면 제품마다 자기 길을 낸다.** 그것이\n * 정확히 「무한히 갈린다」다. 이 파일이 그 갈림을 막는다.\n *\n * ── 커널은 어느 표인지 모른다 ──────────────────────────────────────────────\n * 계약은 「이 축이 밖을 가리킨다」까지만 안다. **「어느 표의 어느 칸」은 제품이 안다.** 그래서 제품이\n * 부팅에서 해소기를 꽂는다 — 장부 기록기(`installIngestWindowWriter`)와 같은 규율이다.\n *\n * 커널이 표 이름을 알게 되면 한 제품의 저장 구조에 매이고, 다음 제품이 다른 표를 쓰면 커널을 또\n * 고쳐야 한다.\n *\n * ── 없는 것을 숨기지 않는다 ────────────────────────────────────────────────\n * 해소기가 없으면 **그 사실을 낸다**(`unresolved`). 링크를 지우면 화면은 「그런 관계가 없다」로\n * 읽고, 실제로는 「아무도 답할 수 없다」다. 그 둘은 사람이 할 일이 다르다.\n */\n\n/** 밖에 사는 대상 하나 — 화면이 이름을 보이고 갈 곳을 정할 수 있게. */\nexport interface ExternalRef {\n id: string\n /** 사람이 읽는 이름. 없으면 화면이 id 를 보인다(지어내지 않는다). */\n name?: string\n /**\n * 그 제품에서 이것을 열 수 있는 자리 — 없으면 화면이 링크를 만들지 않는다.\n *\n * 막다른 링크를 만들지 않는 것이 이 칸의 요점이다. 눌러서 아무 일도 안 일어나면 사람은 그것을\n * 결함으로 읽는다.\n */\n href?: string\n /** 그 id 가 실제로 있나 — **없으면 없다고 말한다.** 답에서 빼지 않는다. */\n missing?: boolean\n}\n\n/**\n * 밖에서 이 대상을 가리키는 것들 — 들어오는 관계 한 묶음.\n *\n * `tense` 를 **제품이 선언한다**(계약의 `RelationTense`). 실적·상태 구간은 지난 일이고 정비 계획·\n * 지시는 앞의 일이다 — 「걸린 것이 열」이 두 뜻이 되는 자리이므로 묶음이 스스로 말해야 한다.\n */\nexport interface ExternalIncoming {\n /** 관계의 이름(i18n 키) — 계약의 `via` 와 같은 규칙. 제품은 자기 접두를 쓴다. */\n via: string\n /** 어디서 오나 — 사람이 읽는 축 이름이 아니라 그 제품의 축 키다. */\n from: string\n /** 언제의 일인가. 안 주면 「선언이 말하지 않았다」다 — `standing` 으로 바꾸지 않는다. */\n tense?: 'past' | 'future' | 'standing'\n ids: string[]\n /** 전체 수 — `ids` 가 잘렸어도 몇인지 말한다. */\n total: number\n}\n\n/**\n * 한 external 대상을 푸는 제품 쪽 구현.\n *\n * 둘 다 선택이다. 이름만 대 줄 수 있는 제품과 들어오는 관계만 낼 수 있는 제품이 다 있다 — 하나만\n * 꽂아도 그만큼은 답해진다.\n */\n/** What the relation being resolved belongs to. Required — a resolver that answers without it answers for the wrong twin. */\nexport interface ExternalResolveContext {\n instanceId: string\n}\n\nexport interface ExternalResolver {\n /** 계약의 `target.entity` 와 **같은 낱말**. 다르면 아무 관계에도 안 붙는다. */\n entity: string\n /**\n * 이 id 들이 무엇인가 — **어느 트윈의 관계인지와 함께** 묻는다.\n *\n * 인스턴스를 넘기는 이유(2026-09-18): 밖의 id 는 도메인에서 유일하지 않다. 구역 id(`areaId`)는\n * 공간 안에서만 유일하므로, 도메인만으로 찾으면 다른 공간의 같은 id 가 답이 되어 **남의 공간 이름**이\n * 이 자리의 부모로 보인다. 부르는 쪽은 인스턴스를 알고 있다.\n */\n resolve?(domainId: string, ids: readonly string[], context: ExternalResolveContext): Promise<ExternalRef[]>\n /** 이 대상을 가리키는 것들. */\n incoming?(domainId: string, instanceId: string, axis: string, itemId: string): Promise<ExternalIncoming[]>\n}\n\nconst resolvers = new Map<string, ExternalResolver>()\n\n/**\n * 해소기를 꽂는다 — **부팅에서 한 번.**\n *\n * 같은 `entity` 를 두 번 꽂으면 뒤엣것이 이긴다. 그것이 개발 중 재적재의 정상 동작이고, 운영에서\n * 일어나면 두 제품이 같은 대상을 자기 것이라 말하는 것이므로 **경고를 남긴다** — 조용히 덮으면\n * 어느 쪽이 답하고 있는지 아무도 모른다.\n */\nexport function registerExternalResolver(r: ExternalResolver, warn?: (msg: string) => void): void {\n const entity = String(r?.entity ?? '').trim()\n if (!entity) {\n warn?.('[external-resolver] entity 없는 해소기는 꽂지 않는다 — 아무 관계에도 안 붙는다')\n return\n }\n if (resolvers.has(entity)) {\n warn?.(`[external-resolver] '${entity}' 해소기를 덮어쓴다 — 두 제품이 같은 대상을 답하려 하면 그중 하나만 답한다`)\n }\n resolvers.set(entity, { ...r, entity })\n}\n\n/** 지금 꽂혀 있는 것들 — 「무엇을 풀 수 있나」를 밖에서 잴 수 있게. */\nexport function externalResolverEntities(): string[] {\n return [...resolvers.keys()].sort()\n}\n\n/** 시험이 재기동을 넣을 수 있게. */\nexport function clearExternalResolvers(): void {\n resolvers.clear()\n}\n\n/**\n * 그 id 들을 푼다 — **못 풀면 못 푼다고 말한다.**\n *\n * 해소기가 터져도 360 전체를 죽이지 않는다. 한 관계가 답을 못 하는 것과 화면이 안 뜨는 것은 다른\n * 사고이고, 뒤엣것이 훨씬 나쁘다.\n */\nexport async function resolveExternal(\n entity: string,\n domainId: string,\n ids: readonly string[],\n context: ExternalResolveContext,\n warn?: (msg: string) => void\n): Promise<{ refs?: ExternalRef[]; unresolved?: 'no-resolver' | 'failed' }> {\n const r = resolvers.get(entity)\n if (!r?.resolve) return { unresolved: 'no-resolver' }\n try {\n return { refs: await r.resolve(domainId, ids, context) }\n } catch (e: any) {\n warn?.(`[external-resolver] '${entity}' 해소가 실패했다 — ${String(e?.message ?? e)}`)\n return { unresolved: 'failed' }\n }\n}\n\n/**\n * 밖에서 이 대상을 가리키는 것들을 모은다.\n *\n * 꽂힌 것이 없으면 **빈 배열이지 오류가 아니다** — 밖에서 가리키는 것이 없는 대상이 정상이다.\n * 다만 「해소기가 없어서 모른다」와 「없다」는 다르므로, 그 구별은 `externalResolverEntities()` 로\n * 밖에서 잰다.\n */\nexport async function externalIncoming(\n domainId: string,\n instanceId: string,\n axis: string,\n itemId: string,\n warn?: (msg: string) => void\n): Promise<ExternalIncoming[]> {\n const out: ExternalIncoming[] = []\n for (const r of resolvers.values()) {\n if (!r.incoming) continue\n try {\n const got = await r.incoming(domainId, instanceId, axis, itemId)\n /* 빈 묶음은 싣지 않는다 — 여기서는 「그 제품이 이 축을 안 본다」는 뜻이고 할 말이 없다. */\n for (const g of got ?? []) if (g && g.total > 0) out.push(g)\n } catch (e: any) {\n warn?.(`[external-resolver] '${r.entity}' 의 들어오는 관계 조회가 실패했다 — ${String(e?.message ?? e)}`)\n }\n }\n return out\n}\n"]}
@@ -354,11 +354,29 @@ let TwinModelItemQuery = class TwinModelItemQuery {
354
354
  /* 같은 규칙 — 축 이름이 아니라 선언된 자리에서 읽는다(§axisValueOf). */
355
355
  const bucket = snap ? (0, axis_read_js_1.axisValueOf)(snap, info) : null;
356
356
  const found = Array.isArray(bucket) ? bucket.find((o) => (0, name_index_js_1.idOf)(o, info) === itemId) : undefined;
357
- if (!snap || !found) {
357
+ /*
358
+ * **상태를 읽었는데 그 개체가 없으면, 그런 개체가 없는 것이다** (2026-09-18).
359
+ *
360
+ * 이 갈래가 `!snap || !found` 하나였다. 그래서 없는 식별자로 물어도 「트윈이 실제로 본 것입니다」
361
+ * 라고 적힌 패널이 돌아왔다 — 화면은 그것을 그려서, 있지도 않은 개체가 값이 비어 있는 개체처럼
362
+ * 보였다. 문서 축은 같은 물음에 `null` 을 답한다(없는 것은 없다고 답한다). 상태 축도 같다.
363
+ *
364
+ * 못 읽은 것은 다른 사실이라 아래에서 따로 답한다 — 「없다」로 접지 않는다.
365
+ */
366
+ if (snap && !found)
367
+ return null;
368
+ if (!snap) {
358
369
  return {
359
370
  instanceId, axis, label: info.label, itemId,
360
- identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null },
361
- declared: null, observed: found ?? null, observedOnly: true,
371
+ identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null, journalAxis: info.journalAxis ?? null, describedBy: info.describedBy ?? null },
372
+ declared: null,
373
+ observed: null,
374
+ observedOnly: true,
375
+ /*
376
+ * **왜 관측 열이 비었나** — 커널이 멈춰 있고 저널로도 되세우지 못했다. 코드는 목록·비교 열이
377
+ * 쓰는 것과 같은 것을 쓴다(`not-running`) — 같은 사실에 두 낱말을 만들지 않는다.
378
+ */
379
+ observedAbsence: 'not-running',
362
380
  basis: liveItem ? 'state' : 'journal', observedBasis: liveItem ? 'state' : 'journal', observing: liveItem,
363
381
  links: { out: [], incoming: [] }, properties: propertiesOf(axis).map((pr) => ({ key: pr.key, label: pr.label, kind: pr.kind, uom: pr.uom ?? null, observedBy: pr.observedBy ?? null })),
364
382
  /* 어떤 속성이 **효과가 있나** — 등록부가 말한다(§property-effects). 없는 것은 표시만 된다. */
@@ -369,7 +387,7 @@ let TwinModelItemQuery = class TwinModelItemQuery {
369
387
  const names = (0, name_index_js_1.namesFor)([{ id: itemId, fields: found }], (0, name_index_js_1.nameIndex)(inst.model ?? {}));
370
388
  return {
371
389
  instanceId, axis, label: info.label, itemId,
372
- identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null },
390
+ identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null, journalAxis: info.journalAxis ?? null, describedBy: info.describedBy ?? null },
373
391
  /*
374
392
  * **선언이 없는 축이다.** 두 열 중 왼쪽이 비는 것이 정상이고, 그 사실을 값으로 말한다
375
393
  * (`observedOnly`) — 빈 열을 "원본이 침묵했다" 로 읽으면 안 된다. 원본은 애초에 이 축을
@@ -448,7 +466,13 @@ let TwinModelItemQuery = class TwinModelItemQuery {
448
466
  standardClass: info.standardClass ?? null,
449
467
  typeRole: info.typeRole ?? null,
450
468
  kind: info.kind,
451
- gs1Scheme: info.gs1Scheme ?? null
469
+ gs1Scheme: info.gs1Scheme ?? null,
470
+ /*
471
+ * 선언 둘을 그대로 나른다 (ADR-0074) — 이 개체의 이력을 저널의 어느 축으로 묻나,
472
+ * 그리고 식별자 말고 무엇이 이 개체를 말하나. 화면이 그 목록을 갖지 않게 한다.
473
+ */
474
+ journalAxis: info.journalAxis ?? null,
475
+ describedBy: info.describedBy ?? null
452
476
  },
453
477
  /*
454
478
  * **그릇인가** — 문서에 객체 하나로 있는 축(`productionSpec`)인지.
@@ -792,7 +816,7 @@ let TwinModelItemQuery = class TwinModelItemQuery {
792
816
  for (const l of out) {
793
817
  if (l.target?.kind !== 'external' || !l.ids.length)
794
818
  continue;
795
- const got = await (0, external_resolver_js_1.resolveExternal)(l.target.entity, domainId, l.ids, m => (0, log_js_1.twinWarn)(m));
819
+ const got = await (0, external_resolver_js_1.resolveExternal)(l.target.entity, domainId, l.ids, { instanceId: inst.instanceId }, m => (0, log_js_1.twinWarn)(m));
796
820
  if (got.refs)
797
821
  l.refs = got.refs;
798
822
  else