@operato/twin-kernel 0.11.2 → 0.11.4

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.
@@ -1311,7 +1311,44 @@ export declare abstract class FlowEngine implements TwinKernel {
1311
1311
  subject: string;
1312
1312
  basis: 'declared' | 'twin-local';
1313
1313
  };
1314
- protected emitOp(eventType: string, data: unknown): void;
1314
+ /**
1315
+ * 운영 델타 방출 — **같은 사실을 다시 적지 않는다.**
1316
+ *
1317
+ * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────
1318
+ * `flow-engine.ts` 의 배정 패스가 **매 틱** `created` 인 오더 전부에 `allocate` 를 건다. 자재가
1319
+ * 모자란 오더는 그때마다 `mes-kernel` 의 「모자람을 적고 다음 틱에 다시 본다」 줄에 닿았고, 거기서
1320
+ * **바뀐 것이 없어도 방출**했다.
1321
+ *
1322
+ * hatio-mx2 오더 315개 → 세 시간에 order.status 2,433,116건 (오더당 7,700)
1323
+ * 다른 트윈 오더당 1~2건
1324
+ * 틱 로그 「journal 706.92ms(88%)」
1325
+ *
1326
+ * 다시 보는 것은 옳다(모자람은 채워질 수 있다). **다시 볼 때마다 적는 것**이 결함이다.
1327
+ *
1328
+ * ── 파급이 디스크가 아니다 ─────────────────────────────────────────────────
1329
+ * 재기동 저널을 접어 상태를 세운다 — 되세우는 시간이 그만큼 는다
1330
+ * 구독 이 사건들은 방송된다. 막힌 오더 × 매 틱 × 구독자 수
1331
+ * 틱 시뮬 틱은 메인 이벤트 루프에서 돈다 — HTTP·구독이 함께 느려진다
1332
+ * 지표 오더 사건을 세는 모든 것이 부푼다
1333
+ * 진짜 문제 「오더가 자재를 못 구해 멈췄다」가 자기가 만든 소음에 묻힌다
1334
+ *
1335
+ * 가장 나쁜 것은 방향이다. **현장이 막힐수록 트윈이 더 무너진다** — 잘 흐르면 사건이 적고 막히면
1336
+ * 폭증한다. 트윈이 가장 필요한 순간에 가장 약해진다.
1337
+ *
1338
+ * ── 왜 여기인가 ────────────────────────────────────────────────────────────
1339
+ * 부르는 자리 하나를 막으면 다른 자리에서 같은 일이 난다 — `emitOrder` 를 부르는 곳만 열다섯이다.
1340
+ * 그리고 `revision` 이 여기서 오르므로, 여기서 막지 않으면 **적지 않고도 판만 올라간다.**
1341
+ *
1342
+ * ── 무엇을 같다고 보나 ─────────────────────────────────────────────────────
1343
+ * `data` 가 바이트까지 같으면 같다. 시각은 `data` 에 없다(여기서 붙인다) — 그래서 「같은 사실을
1344
+ * 다른 시각에 다시 말한 것」이 정확히 걸러진다.
1345
+ *
1346
+ * `subject` 를 안 주면 거르지 않는다. 대상이 없는 사건(주의 등)은 반복 자체가 사실이다.
1347
+ *
1348
+ * 재기동하면 이 기억이 비므로 **첫 방출은 언제나 나간다** — 되세운 상태가 조용히 빠지지 않는다.
1349
+ */
1350
+ private lastEmitted;
1351
+ protected emitOp(eventType: string, data: unknown, subject?: string): void;
1315
1352
  /**
1316
1353
  * 작업 전이 방출 — **커널이 아는 것을 미러도 알게** 한다.
1317
1354
  *
@@ -2820,7 +2820,51 @@ export class FlowEngine {
2820
2820
  subjectOf(kind, localId) {
2821
2821
  return resolveSubject(kind, localId, { tenantId: this.tenantId, scopeId: this.scopeId, identityOf: (k, id) => (k === 'equipment' ? this.equipmentIdentity(id) : undefined) });
2822
2822
  }
2823
- emitOp(eventType, data) {
2823
+ /**
2824
+ * 운영 델타 방출 — **같은 사실을 다시 적지 않는다.**
2825
+ *
2826
+ * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────
2827
+ * `flow-engine.ts` 의 배정 패스가 **매 틱** `created` 인 오더 전부에 `allocate` 를 건다. 자재가
2828
+ * 모자란 오더는 그때마다 `mes-kernel` 의 「모자람을 적고 다음 틱에 다시 본다」 줄에 닿았고, 거기서
2829
+ * **바뀐 것이 없어도 방출**했다.
2830
+ *
2831
+ * hatio-mx2 오더 315개 → 세 시간에 order.status 2,433,116건 (오더당 7,700)
2832
+ * 다른 트윈 오더당 1~2건
2833
+ * 틱 로그 「journal 706.92ms(88%)」
2834
+ *
2835
+ * 다시 보는 것은 옳다(모자람은 채워질 수 있다). **다시 볼 때마다 적는 것**이 결함이다.
2836
+ *
2837
+ * ── 파급이 디스크가 아니다 ─────────────────────────────────────────────────
2838
+ * 재기동 저널을 접어 상태를 세운다 — 되세우는 시간이 그만큼 는다
2839
+ * 구독 이 사건들은 방송된다. 막힌 오더 × 매 틱 × 구독자 수
2840
+ * 틱 시뮬 틱은 메인 이벤트 루프에서 돈다 — HTTP·구독이 함께 느려진다
2841
+ * 지표 오더 사건을 세는 모든 것이 부푼다
2842
+ * 진짜 문제 「오더가 자재를 못 구해 멈췄다」가 자기가 만든 소음에 묻힌다
2843
+ *
2844
+ * 가장 나쁜 것은 방향이다. **현장이 막힐수록 트윈이 더 무너진다** — 잘 흐르면 사건이 적고 막히면
2845
+ * 폭증한다. 트윈이 가장 필요한 순간에 가장 약해진다.
2846
+ *
2847
+ * ── 왜 여기인가 ────────────────────────────────────────────────────────────
2848
+ * 부르는 자리 하나를 막으면 다른 자리에서 같은 일이 난다 — `emitOrder` 를 부르는 곳만 열다섯이다.
2849
+ * 그리고 `revision` 이 여기서 오르므로, 여기서 막지 않으면 **적지 않고도 판만 올라간다.**
2850
+ *
2851
+ * ── 무엇을 같다고 보나 ─────────────────────────────────────────────────────
2852
+ * `data` 가 바이트까지 같으면 같다. 시각은 `data` 에 없다(여기서 붙인다) — 그래서 「같은 사실을
2853
+ * 다른 시각에 다시 말한 것」이 정확히 걸러진다.
2854
+ *
2855
+ * `subject` 를 안 주면 거르지 않는다. 대상이 없는 사건(주의 등)은 반복 자체가 사실이다.
2856
+ *
2857
+ * 재기동하면 이 기억이 비므로 **첫 방출은 언제나 나간다** — 되세운 상태가 조용히 빠지지 않는다.
2858
+ */
2859
+ lastEmitted = new Map();
2860
+ emitOp(eventType, data, subject) {
2861
+ if (subject !== undefined) {
2862
+ const key = `${eventType}\u0000${subject}`;
2863
+ const json = JSON.stringify(data);
2864
+ if (this.lastEmitted.get(key) === json)
2865
+ return;
2866
+ this.lastEmitted.set(key, json);
2867
+ }
2824
2868
  this.revision++;
2825
2869
  const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
2826
2870
  for (const h of this.handlers)
@@ -2852,14 +2896,14 @@ export class FlowEngine {
2852
2896
  ...(t.startTime ? { startTime: t.startTime } : {}),
2853
2897
  ...(t.endTime ? { endTime: t.endTime } : {}),
2854
2898
  ...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
2855
- });
2899
+ }, t.id);
2856
2900
  }
2857
2901
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
2858
2902
  emitAsset(a) {
2859
- this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, ...(a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}), ...(a.usedUsage ? { usedUsage: a.usedUsage } : {}), status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying, ...effectiveOnly(a) });
2903
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, ...(a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}), ...(a.usedUsage ? { usedUsage: a.usedUsage } : {}), status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying, ...effectiveOnly(a) }, a.id);
2860
2904
  }
2861
2905
  emitPerson(p) {
2862
- this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...effectiveOnly(p) });
2906
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...effectiveOnly(p) }, p.id);
2863
2907
  /* `offShift` 를 더 이상 실어 보내지 않는다 — 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로
2864
2908
  전이 순간의 판정을 보내면 유휴로 경계를 지난 사람이 옛 값에 머문다. 미러는 같은 함수
2865
2909
  (`offCalendarAt`)를 자기 시각으로 부른다(유효 기간과 같은 규율). */
@@ -2867,7 +2911,7 @@ export class FlowEngine {
2867
2911
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
2868
2912
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
2869
2913
  emitEquipment(m, motion) {
2870
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), status: m.status, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), taskId: m.taskId ?? undefined, motion, ...(m.held ? { held: true } : {}), ...effectiveOnly(m) });
2914
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, ...(m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}), status: m.status, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), taskId: m.taskId ?? undefined, motion, ...(m.held ? { held: true } : {}), ...effectiveOnly(m) }, m.id);
2871
2915
  }
2872
2916
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
2873
2917
  emitOrder(o) {
@@ -2888,7 +2932,7 @@ export class FlowEngine {
2888
2932
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
2889
2933
  ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
2890
2934
  ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
2891
- });
2935
+ }, o.id);
2892
2936
  }
2893
2937
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
2894
2938
  /**
@@ -50,6 +50,18 @@ export interface MasterUpdate {
50
50
  }
51
51
  export interface ProjectedState {
52
52
  revision: number;
53
+ /**
54
+ * **지금 아는 어휘 항목 수** — 「무엇을 알고 있나」를 밖에서 잴 수 있게.
55
+ *
56
+ * ── 왜 수만 내나 (2026-09-05) ─────────────────────────────────────────────
57
+ * 어휘 표 자체는 클 수 있다(로트는 계속 새로 생긴다 — 하루 수백이면 1년에 수십만). 그것을 매
58
+ * 스냅샷에 통째로 실으면 스냅샷이 그 표의 크기를 따라간다.
59
+ *
60
+ * 그래서 여기는 **수**만 낸다. 내용은 대상 하나씩 `masterDataOf()` 로 묻는다.
61
+ *
62
+ * 0 이 아니라 없을 수도 있다 — 어휘를 한 번도 안 받은 트윈에는 이 칸이 없다.
63
+ */
64
+ masterDataSize?: number;
53
65
  /**
54
66
  * 받은 정정 선언들 — 상태에 반영하지 않은 것을 **밝힌다**.
55
67
  * 비어 있지 않으면 "원 시스템이 정정을 보냈고 우리는 아직 반영하지 못했다" 는 뜻이다(조용한 무시 금지).
@@ -407,6 +419,20 @@ export declare class ObservedReducer {
407
419
  * 비직렬 재고는 물품의 키가 곧 클래스 식별자다. 직렬 개체는 클래스가 따로 있다. 두 모양이 다 오므로
408
420
  * 둘 다 본다 — 한쪽만 보면 그 원본에서는 값이 조용히 비어 있게 된다.
409
421
  */
422
+ /**
423
+ * **이 클래스에 대해 아는 것** — 수량으로만 말하는 원본에서 로트 어휘를 읽는 길.
424
+ *
425
+ * ── 왜 필요한가 (인티그레이션 레인이 올림, 2026-09-05) ─────────────────────
426
+ * 어휘 요소(로트 번호·소비기한)는 들어와서 `classMaster` 에 앉고 검사점에도 실린다. 그런데
427
+ * **밖에서 물을 길이 없었다** — `masterAttributesOf` 는 사적이고, 물품을 꾸미는 데만 쓰였다.
428
+ *
429
+ * 그리고 그 꾸미기는 **이미 서 있는 물품만** 다시 센다. 낱개에 일련번호를 안 붙이는 원본은
430
+ * `epcList` 를 비워 보내므로 걸릴 물품이 아예 없고, 그러면 로트 번호·소비기한이 **올 리 없는
431
+ * 물품을 기다린다.** 잃지는 않는데 아무도 안 본다.
432
+ *
433
+ * 그 원본에서는 **클래스가 주어**다. 물품이 아니라 클래스에게 물어야 답이 나온다.
434
+ */
435
+ masterDataOf(classUri: string | undefined): Record<string, unknown> | undefined;
410
436
  private masterAttributesOf;
411
437
  /**
412
438
  * **마스터데이터를 받는다** — 어휘 요소의 속성을 상태에 세운다(§`master-data`).
@@ -991,6 +991,22 @@ export class ObservedReducer {
991
991
  * 비직렬 재고는 물품의 키가 곧 클래스 식별자다. 직렬 개체는 클래스가 따로 있다. 두 모양이 다 오므로
992
992
  * 둘 다 본다 — 한쪽만 보면 그 원본에서는 값이 조용히 비어 있게 된다.
993
993
  */
994
+ /**
995
+ * **이 클래스에 대해 아는 것** — 수량으로만 말하는 원본에서 로트 어휘를 읽는 길.
996
+ *
997
+ * ── 왜 필요한가 (인티그레이션 레인이 올림, 2026-09-05) ─────────────────────
998
+ * 어휘 요소(로트 번호·소비기한)는 들어와서 `classMaster` 에 앉고 검사점에도 실린다. 그런데
999
+ * **밖에서 물을 길이 없었다** — `masterAttributesOf` 는 사적이고, 물품을 꾸미는 데만 쓰였다.
1000
+ *
1001
+ * 그리고 그 꾸미기는 **이미 서 있는 물품만** 다시 센다. 낱개에 일련번호를 안 붙이는 원본은
1002
+ * `epcList` 를 비워 보내므로 걸릴 물품이 아예 없고, 그러면 로트 번호·소비기한이 **올 리 없는
1003
+ * 물품을 기다린다.** 잃지는 않는데 아무도 안 본다.
1004
+ *
1005
+ * 그 원본에서는 **클래스가 주어**다. 물품이 아니라 클래스에게 물어야 답이 나온다.
1006
+ */
1007
+ masterDataOf(classUri) {
1008
+ return classUri ? this.classMaster.attributesOf(classUri) : undefined;
1009
+ }
994
1010
  masterAttributesOf(classUri, epc) {
995
1011
  return this.classMaster.attributesOf(classUri) ?? this.classMaster.attributesOf(epc);
996
1012
  }
@@ -1303,6 +1319,7 @@ export class ObservedReducer {
1303
1319
  occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
1304
1320
  return {
1305
1321
  revision: this.revision,
1322
+ ...(this.classMaster.size ? { masterDataSize: this.classMaster.size } : {}),
1306
1323
  ...(this.corrections.length ? { corrections: this.corrections.map(c => ({ ...c })) } : {}),
1307
1324
  ...(this.unhandled.size
1308
1325
  ? { unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })).sort((a, b) => b.count - a.count) }
@@ -792,6 +792,7 @@ var ENERGY_EVENT = {
792
792
  */
793
793
  drSuggested: "energy.dr.suggested"
794
794
  };
795
+ var MATERIAL_ACTUAL_USE = ["consumed", "produced"];
795
796
  var CMD = {
796
797
  orderHold: "order.hold",
797
798
  orderResume: "order.resume",
@@ -1574,13 +1575,23 @@ function objectUri(namespace, objId) {
1574
1575
  function bizTransactionUri(namespace, transId) {
1575
1576
  return underNamespace(namespace, "bt", transId);
1576
1577
  }
1578
+ var STRUCTURE_BREAKING = /[%/:?# ]/g;
1579
+ var PERCENT_ENCODED = {
1580
+ "%": "%25",
1581
+ "/": "%2F",
1582
+ ":": "%3A",
1583
+ "?": "%3F",
1584
+ "#": "%23",
1585
+ " ": "%20"
1586
+ };
1577
1587
  function underNamespace(namespace, marker, id) {
1578
1588
  const ns = namespace?.trim();
1579
1589
  if (!ns)
1580
1590
  return void 0;
1581
- const v = String(id);
1582
- if (!v || v.includes("/") || v.includes(":"))
1591
+ const raw = String(id);
1592
+ if (!raw)
1583
1593
  return void 0;
1594
+ const v = raw.replace(STRUCTURE_BREAKING, (c) => PERCENT_ENCODED[c]);
1584
1595
  if (/^https?:\/\/[^/\s]+/.test(ns))
1585
1596
  return `${ns.replace(/\/+$/, "")}/${marker}/${v}`;
1586
1597
  if (/^urn:epc(global)?:/.test(ns))
@@ -1770,6 +1781,27 @@ var SPECS = {
1770
1781
  materialActual: "object[]",
1771
1782
  recordTime: "string"
1772
1783
  },
1784
+ /*
1785
+ * **자재 줄의 안쪽** — 계약의 `MaterialActual` 그대로다.
1786
+ *
1787
+ * `definitionId` 를 요구하는 이유: 이 이름이 없으면 그 줄이 무엇을 가리키는지 아무도 모른다.
1788
+ * 실제로 `materialDefinitionId` 로 온 줄이 저널에 앉았고, 계약의 타입으로 읽으면 비어 있었다.
1789
+ *
1790
+ * `quantity` 를 `positive` 에 두는 이유: 「모른다」는 **비워서** 말한다. 0 으로 실으면 받는
1791
+ * 쪽에서 「0개」와 구별되지 않고, 소요량 집계가 조용히 틀린다.
1792
+ *
1793
+ * `uom` 은 요구하지 않는다 — 계약에서 선택이다(§`MaterialActual`). ERP 경로는 요구하는데
1794
+ * 그것은 **그 경로의 규칙**이다(정산에 단위 없는 수량을 쓸 수 없다). 커널이 그 규칙을 통째로
1795
+ * 물려받으면 단위를 모르는 현장의 사실이 통째로 거절된다.
1796
+ */
1797
+ shapes: {
1798
+ materialActual: {
1799
+ required: ["definitionId", "use"],
1800
+ fields: { definitionId: "string", lotId: "string", quantity: "number", uom: "string" },
1801
+ enums: { use: MATERIAL_ACTUAL_USE },
1802
+ positive: ["quantity"]
1803
+ }
1804
+ },
1773
1805
  enums: {
1774
1806
  status: TASK_STATUS,
1775
1807
  intent: ["transport", "process", "dwell"],
@@ -3051,6 +3083,22 @@ var ObservedReducer = class {
3051
3083
  * 비직렬 재고는 물품의 키가 곧 클래스 식별자다. 직렬 개체는 클래스가 따로 있다. 두 모양이 다 오므로
3052
3084
  * 둘 다 본다 — 한쪽만 보면 그 원본에서는 값이 조용히 비어 있게 된다.
3053
3085
  */
3086
+ /**
3087
+ * **이 클래스에 대해 아는 것** — 수량으로만 말하는 원본에서 로트 어휘를 읽는 길.
3088
+ *
3089
+ * ── 왜 필요한가 (인티그레이션 레인이 올림, 2026-09-05) ─────────────────────
3090
+ * 어휘 요소(로트 번호·소비기한)는 들어와서 `classMaster` 에 앉고 검사점에도 실린다. 그런데
3091
+ * **밖에서 물을 길이 없었다** — `masterAttributesOf` 는 사적이고, 물품을 꾸미는 데만 쓰였다.
3092
+ *
3093
+ * 그리고 그 꾸미기는 **이미 서 있는 물품만** 다시 센다. 낱개에 일련번호를 안 붙이는 원본은
3094
+ * `epcList` 를 비워 보내므로 걸릴 물품이 아예 없고, 그러면 로트 번호·소비기한이 **올 리 없는
3095
+ * 물품을 기다린다.** 잃지는 않는데 아무도 안 본다.
3096
+ *
3097
+ * 그 원본에서는 **클래스가 주어**다. 물품이 아니라 클래스에게 물어야 답이 나온다.
3098
+ */
3099
+ masterDataOf(classUri) {
3100
+ return classUri ? this.classMaster.attributesOf(classUri) : void 0;
3101
+ }
3054
3102
  masterAttributesOf(classUri, epc) {
3055
3103
  return this.classMaster.attributesOf(classUri) ?? this.classMaster.attributesOf(epc);
3056
3104
  }
@@ -3320,6 +3368,7 @@ var ObservedReducer = class {
3320
3368
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
3321
3369
  return {
3322
3370
  revision: this.revision,
3371
+ ...this.classMaster.size ? { masterDataSize: this.classMaster.size } : {},
3323
3372
  ...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
3324
3373
  ...this.unhandled.size ? { unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })).sort((a, b) => b.count - a.count) } : {},
3325
3374
  locations: [...this.master.values()].map((n) => {
@@ -5718,7 +5767,50 @@ var FlowEngine = class {
5718
5767
  subjectOf(kind, localId) {
5719
5768
  return resolveSubject(kind, localId, { tenantId: this.tenantId, scopeId: this.scopeId, identityOf: (k, id) => k === "equipment" ? this.equipmentIdentity(id) : void 0 });
5720
5769
  }
5721
- emitOp(eventType, data) {
5770
+ /**
5771
+ * 운영 델타 방출 — **같은 사실을 다시 적지 않는다.**
5772
+ *
5773
+ * ── 무엇이 있었나 (2026-09-05 실측) ────────────────────────────────────────
5774
+ * `flow-engine.ts` 의 배정 패스가 **매 틱** `created` 인 오더 전부에 `allocate` 를 건다. 자재가
5775
+ * 모자란 오더는 그때마다 `mes-kernel` 의 「모자람을 적고 다음 틱에 다시 본다」 줄에 닿았고, 거기서
5776
+ * **바뀐 것이 없어도 방출**했다.
5777
+ *
5778
+ * hatio-mx2 오더 315개 → 세 시간에 order.status 2,433,116건 (오더당 7,700)
5779
+ * 다른 트윈 오더당 1~2건
5780
+ * 틱 로그 「journal 706.92ms(88%)」
5781
+ *
5782
+ * 다시 보는 것은 옳다(모자람은 채워질 수 있다). **다시 볼 때마다 적는 것**이 결함이다.
5783
+ *
5784
+ * ── 파급이 디스크가 아니다 ─────────────────────────────────────────────────
5785
+ * 재기동 저널을 접어 상태를 세운다 — 되세우는 시간이 그만큼 는다
5786
+ * 구독 이 사건들은 방송된다. 막힌 오더 × 매 틱 × 구독자 수
5787
+ * 틱 시뮬 틱은 메인 이벤트 루프에서 돈다 — HTTP·구독이 함께 느려진다
5788
+ * 지표 오더 사건을 세는 모든 것이 부푼다
5789
+ * 진짜 문제 「오더가 자재를 못 구해 멈췄다」가 자기가 만든 소음에 묻힌다
5790
+ *
5791
+ * 가장 나쁜 것은 방향이다. **현장이 막힐수록 트윈이 더 무너진다** — 잘 흐르면 사건이 적고 막히면
5792
+ * 폭증한다. 트윈이 가장 필요한 순간에 가장 약해진다.
5793
+ *
5794
+ * ── 왜 여기인가 ────────────────────────────────────────────────────────────
5795
+ * 부르는 자리 하나를 막으면 다른 자리에서 같은 일이 난다 — `emitOrder` 를 부르는 곳만 열다섯이다.
5796
+ * 그리고 `revision` 이 여기서 오르므로, 여기서 막지 않으면 **적지 않고도 판만 올라간다.**
5797
+ *
5798
+ * ── 무엇을 같다고 보나 ─────────────────────────────────────────────────────
5799
+ * `data` 가 바이트까지 같으면 같다. 시각은 `data` 에 없다(여기서 붙인다) — 그래서 「같은 사실을
5800
+ * 다른 시각에 다시 말한 것」이 정확히 걸러진다.
5801
+ *
5802
+ * `subject` 를 안 주면 거르지 않는다. 대상이 없는 사건(주의 등)은 반복 자체가 사실이다.
5803
+ *
5804
+ * 재기동하면 이 기억이 비므로 **첫 방출은 언제나 나간다** — 되세운 상태가 조용히 빠지지 않는다.
5805
+ */
5806
+ lastEmitted = /* @__PURE__ */ new Map();
5807
+ emitOp(eventType, data, subject) {
5808
+ if (subject !== void 0) {
5809
+ const key = `${eventType}\0${subject}`;
5810
+ const json = JSON.stringify(data);
5811
+ if (this.lastEmitted.get(key) === json) return;
5812
+ this.lastEmitted.set(key, json);
5813
+ }
5722
5814
  this.revision++;
5723
5815
  const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data };
5724
5816
  for (const h of this.handlers) h(e);
@@ -5755,19 +5847,19 @@ var FlowEngine = class {
5755
5847
  ...t.startTime ? { startTime: t.startTime } : {},
5756
5848
  ...t.endTime ? { endTime: t.endTime } : {},
5757
5849
  ...inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
5758
- });
5850
+ }, t.id);
5759
5851
  }
5760
5852
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
5761
5853
  emitAsset(a) {
5762
- this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}, ...a.usedUsage ? { usedUsage: a.usedUsage } : {}, status: a.status, location: a.location, taskId: a.taskId ?? void 0, carrying: a.carrying, ...effectiveOnly(a) });
5854
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, ...a.ratedUsage ? { ratedUsage: a.ratedUsage } : {}, ...a.usedUsage ? { usedUsage: a.usedUsage } : {}, status: a.status, location: a.location, taskId: a.taskId ?? void 0, carrying: a.carrying, ...effectiveOnly(a) }, a.id);
5763
5855
  }
5764
5856
  emitPerson(p) {
5765
- this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? void 0, ...p.location ? { location: p.location } : {}, ...effectiveOnly(p) });
5857
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? void 0, ...p.location ? { location: p.location } : {}, ...effectiveOnly(p) }, p.id);
5766
5858
  }
5767
5859
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
5768
5860
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
5769
5861
  emitEquipment(m, motion) {
5770
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, status: m.status, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, taskId: m.taskId ?? void 0, motion, ...m.held ? { held: true } : {}, ...effectiveOnly(m) });
5862
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, ...m.equipmentClassIds ? { equipmentClassIds: m.equipmentClassIds } : {}, status: m.status, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, taskId: m.taskId ?? void 0, motion, ...m.held ? { held: true } : {}, ...effectiveOnly(m) }, m.id);
5771
5863
  }
5772
5864
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
5773
5865
  emitOrder(o) {
@@ -5793,7 +5885,7 @@ var FlowEngine = class {
5793
5885
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
5794
5886
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
5795
5887
  ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
5796
- });
5888
+ }, o.id);
5797
5889
  }
5798
5890
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
5799
5891
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {