@operato/twin-kernel 0.7.62 → 0.7.63

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.
@@ -366,6 +366,18 @@ export interface TestResultFact extends TestResult {
366
366
  /** 무엇을 시험했나 — 표준 `TestResult.TestableObjectID`. 물품이면 EPC. */
367
367
  testableObjectId: string;
368
368
  }
369
+ /**
370
+ * `OP_EVENT.complete` 의 내용 — 「이 목록이 전부다」.
371
+ *
372
+ * `axis` 를 받아 두는 이유: 지금은 물품만 이 방식이 필요하지만, 다른 목록도 같은 성질을 갖는다
373
+ * (연결된 시스템이 현재 목록을 통째로 말하는 것). 낱말을 닫으면 그때 계약을 또 고친다.
374
+ */
375
+ export interface AxisCompleteFact {
376
+ /** 어느 목록인가 — 지금은 `'items'` 만 다룬다. */
377
+ completeAxis: string;
378
+ /** 그 주기를 **시작한** 시각(ISO). 이보다 오래된 것은 이 주기에 오지 않은 것이다. */
379
+ since: ISOTime;
380
+ }
369
381
  /**
370
382
  * 이 시험 결과가 **이 시각에 유효한 합격인가.**
371
383
  *
@@ -2180,6 +2192,27 @@ export declare const OP_EVENT: {
2180
2192
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
2181
2193
  */
2182
2194
  readonly test: "test.result";
2195
+ /**
2196
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
2197
+ *
2198
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
2199
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
2200
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
2201
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
2202
+ *
2203
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
2204
+ *
2205
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
2206
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
2207
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
2208
+ * 사라진다.
2209
+ *
2210
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
2211
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
2212
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
2213
+ * 지워지지 않아야 한다.
2214
+ */
2215
+ readonly complete: "axis.complete";
2183
2216
  /**
2184
2217
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
2185
2218
  *
package/dist/contract.js CHANGED
@@ -944,6 +944,27 @@ export const OP_EVENT = {
944
944
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
945
945
  */
946
946
  test: 'test.result',
947
+ /**
948
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
949
+ *
950
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
951
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
952
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
953
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
954
+ *
955
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
956
+ *
957
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
958
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
959
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
960
+ * 사라진다.
961
+ *
962
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
963
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
964
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
965
+ * 지워지지 않아야 한다.
966
+ */
967
+ complete: 'axis.complete',
947
968
  /**
948
969
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
949
970
  *
@@ -18,6 +18,14 @@ interface ProjItem {
18
18
  expiry?: number;
19
19
  /** 이 로트의 시험 결과 — **명세당 최신 하나**(상태가 계측 주기로 자라지 않게). */
20
20
  testResults?: TestResult[];
21
+ /**
22
+ * **마지막으로 관측된 시각**(ms) — 「이 목록이 전부다」를 받았을 때 무엇을 지울지 가리는 값.
23
+ *
24
+ * 물품 안에 두는 이유: 순서 판정에 쓰는 시각(`lastAt`)은 **중간 저장본에 들어가지 않는다.**
25
+ * 그것에 기대면 재기동 직후 첫 신호가 재고를 전부 지운다(본 적이 없는 것으로 보이므로).
26
+ * 물품은 중간 저장본에 들어가므로 이 값도 함께 살아남는다.
27
+ */
28
+ seenAtMs?: number;
21
29
  }
22
30
  /**
23
31
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
@@ -435,6 +435,44 @@ export class ObservedReducer {
435
435
  item.testResults = [...kept, next];
436
436
  break;
437
437
  }
438
+ case OP_EVENT.complete: {
439
+ /*
440
+ * ── ★ **이 목록이 전부다** (2026-08-25) ────────────────────────────────
441
+ *
442
+ * 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다. 이 주기에 오지 않은 것은
443
+ * **그 시스템에 더 이상 없는 것**이므로 지운다.
444
+ *
445
+ * 이것이 없던 동안 트윈은 받은 것을 **더하기만 했다** — 목록에서 빠진 줄은 아무 말도 오지 않은
446
+ * 것이 되어 영원히 남았다(실측: 실제 1,704건인데 트윈이 10,038건).
447
+ *
448
+ * ── 아무것도 못 들은 주기에는 지우지 않는다 ────────────────────────────
449
+ * 그 주기에 관측이 **한 건도** 없었다면 그것은 「다 없어졌다」가 아니라 **우리가 못 들은 것**이다.
450
+ * 그때 지우면 살아 있는 재고가 사라진다 — 이 저장소가 여러 번 거절한 모양이다(결측 ≠ 없음).
451
+ *
452
+ * 보내는 쪽의 규율은 계약에 적었다: **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 보내면
453
+ * 살아 있는 재고를 지운다. 그 판단은 보내는 쪽만 할 수 있다.
454
+ *
455
+ * 지운 수를 `unhandled` 로 세지 않는다 — 그것은 반영하지 못한 사건을 세는 자리다. 여기서 지운
456
+ * 것은 **정상적으로 반영한 결과**다.
457
+ */
458
+ const d = e.data;
459
+ if (d?.completeAxis !== 'items')
460
+ break;
461
+ const since = Date.parse(String(d.since ?? ''));
462
+ if (!Number.isFinite(since))
463
+ break;
464
+ let heard = 0;
465
+ for (const it of this.items.values())
466
+ if ((it.seenAtMs ?? -1) >= since)
467
+ heard++;
468
+ if (!heard)
469
+ break;
470
+ for (const [key, it] of [...this.items.entries()]) {
471
+ if ((it.seenAtMs ?? -1) < since)
472
+ this.remove(key);
473
+ }
474
+ break;
475
+ }
438
476
  case OP_EVENT.attentionAck: {
439
477
  /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
440
478
  const d = e.data;
@@ -632,6 +670,45 @@ export class ObservedReducer {
632
670
  if (ev.action === 'DELETE') {
633
671
  for (const epc of ev.epcList)
634
672
  this.remove(epc);
673
+ /*
674
+ * ── ★ **수량으로 온 물품도 없어질 수 있어야 한다** (2026-08-25 실측) ────────
675
+ *
676
+ * 여기는 `epcList` 만 봤다. 그래서 **낱개 번호가 없는 현장에서는 재고를 지울 방법이 하나도
677
+ * 없었다** — 승화푸드는 모든 재고가 수량으로 오는 현장이다.
678
+ *
679
+ * 결과: 한 번 본 재고가 영원히 남았다. 연결된 시스템의 목록에서 그 줄이 없어져도 우리는
680
+ * 「없어졌다」를 들을 길이 없었다(있는 것만 오고, 없어진 것은 아무 말도 오지 않는다).
681
+ * 실측으로 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
682
+ *
683
+ * ── 무엇을 지우는지는 말한 범위대로 ────────────────────────────────────
684
+ * 자리를 함께 말했다 그 자리의 그 로트만 지운다 (꺼내 가서 그 칸이 빈 경우)
685
+ * 자리를 말하지 않았다 그 로트를 모든 자리에서 지운다 (그 로트가 통째로 없어진 경우)
686
+ *
687
+ * 짐작이 아니라 **진술의 범위를 읽는 것**이다. 자리를 말했으면 그 자리를 말한 것이고,
688
+ * 말하지 않았으면 자리를 가리지 않고 말한 것이다.
689
+ *
690
+ * 순서 판정을 함께 지난다: 늦게 온 옛 「없어졌다」가 그 뒤에 다시 들어온 재고를 지우면 안 된다.
691
+ * 열쇠는 관측과 같은 것을 쓴다 — 같은 대상의 두 방향이므로 한 열쇠로 재야 한다.
692
+ */
693
+ const goneAt = ev.readPoint?.id;
694
+ for (const qe of ev.quantityList ?? []) {
695
+ if (!qe?.epcClass)
696
+ continue;
697
+ if (goneAt) {
698
+ const key = `${qe.epcClass}@${goneAt}`;
699
+ if (envelope && this.stale(`item:${key}`, envelope))
700
+ continue;
701
+ this.remove(key);
702
+ continue;
703
+ }
704
+ for (const key of [...this.items.keys()]) {
705
+ if (!key.startsWith(`${qe.epcClass}@`))
706
+ continue;
707
+ if (envelope && this.stale(`item:${key}`, envelope))
708
+ continue;
709
+ this.remove(key);
710
+ }
711
+ }
635
712
  return;
636
713
  }
637
714
  const loc = ev.readPoint?.id;
@@ -646,7 +723,9 @@ export class ObservedReducer {
646
723
  /* 물품별 순서 판정 — 늦게 온 옛 관측이 최신 위치를 덮지 않게. */
647
724
  if (envelope && this.stale(`item:${epc}`, envelope))
648
725
  continue;
649
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all));
726
+ const seen = this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all);
727
+ const atSeen = Date.parse(String(envelope?.eventTime ?? ''));
728
+ this.items.set(itemKeyOf(seen), Number.isFinite(atSeen) ? { ...seen, seenAtMs: atSeen } : seen);
650
729
  }
651
730
  /* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
652
731
  * 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
@@ -683,7 +762,9 @@ export class ObservedReducer {
683
762
  */
684
763
  if (envelope && this.stale(`item:${subLotId}`, envelope))
685
764
  continue;
686
- this.items.set(subLotId, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId));
765
+ const seenQty = this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId);
766
+ const atQty = Date.parse(String(envelope?.eventTime ?? ''));
767
+ this.items.set(subLotId, Number.isFinite(atQty) ? { ...seenQty, seenAtMs: atQty } : seenQty);
687
768
  }
688
769
  }
689
770
  }
@@ -936,8 +1017,16 @@ export class ObservedReducer {
936
1017
  origin: n.origin
937
1018
  };
938
1019
  }),
939
- /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
940
- items: [...this.items.values()].map(i => ({ ...i })),
1020
+ /*
1021
+ * 갖고 있는 것을 전부 내보낸다 — 줄이면 그 자리에서 정보가 사라진다.
1022
+ *
1023
+ * **`seenAtMs` 는 뺀다.** 그것은 물품에 대한 사실이 아니라 **우리가 언제 들었나**이고, 「이 목록이
1024
+ * 전부다」를 받았을 때 무엇을 지울지 가리는 데만 쓴다. 화면이 알 필요가 없고, 상태에 내보내면
1025
+ * 자체 구동에는 없는 값이라 두 구동이 어긋난다(적합성 검사가 그것을 잡았다).
1026
+ *
1027
+ * 중간 저장본에는 남는다 — 재기동 뒤에도 무엇을 지울지 가릴 수 있어야 한다.
1028
+ */
1029
+ items: [...this.items.values()].map(({ seenAtMs: _seen, ...i }) => ({ ...i })),
941
1030
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
942
1031
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
943
1032
  persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
@@ -1,6 +1,6 @@
1
1
  import type { IngestResult } from './face2-adapter.ts';
2
2
  /** 이 문이 받는 여섯 가지 — 리듀서가 접는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
3
- export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'observation';
3
+ export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'observation' | 'complete';
4
4
  /**
5
5
  * 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
6
6
  *
@@ -151,6 +151,18 @@ const SPECS = {
151
151
  * `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
152
152
  * 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
153
153
  */
154
+ /*
155
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 알린다.
156
+ *
157
+ * 둘 다 요구한다: 어느 목록인지와 그 주기를 **시작한** 시각. 시각이 없으면 무엇을 지울지 가릴 수
158
+ * 없고, 그때 지우면 전부 지운다 — 받지 않는 것이 옳다.
159
+ */
160
+ complete: {
161
+ eventType: OP_EVENT.complete,
162
+ identity: 'completeAxis',
163
+ required: ['completeAxis', 'since'],
164
+ fields: { completeAxis: 'string', since: 'string', recordTime: 'string' }
165
+ },
154
166
  observation: {
155
167
  eventType: OP_EVENT.observation,
156
168
  identity: 'locationId',
@@ -223,6 +235,9 @@ export function operationalKindOf(record) {
223
235
  */
224
236
  if (has('locationId') && has('propertyId'))
225
237
  return 'observation';
238
+ /* 「이 목록이 전부다」 — 어느 목록인지를 스스로 말하므로 다른 사실과 섞이지 않는다. */
239
+ if (has('completeAxis'))
240
+ return 'complete';
226
241
  return undefined;
227
242
  }
228
243
  /** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
@@ -614,6 +614,27 @@ var OP_EVENT = {
614
614
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
615
615
  */
616
616
  test: "test.result",
617
+ /**
618
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
619
+ *
620
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
621
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
622
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
623
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
624
+ *
625
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
626
+ *
627
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
628
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
629
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
630
+ * 사라진다.
631
+ *
632
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
633
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
634
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
635
+ * 지워지지 않아야 한다.
636
+ */
637
+ complete: "axis.complete",
617
638
  /**
618
639
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
619
640
  *
@@ -1668,6 +1689,19 @@ var ObservedReducer = class {
1668
1689
  item.testResults = [...kept, next];
1669
1690
  break;
1670
1691
  }
1692
+ case OP_EVENT.complete: {
1693
+ const d = e.data;
1694
+ if (d?.completeAxis !== "items") break;
1695
+ const since = Date.parse(String(d.since ?? ""));
1696
+ if (!Number.isFinite(since)) break;
1697
+ let heard = 0;
1698
+ for (const it of this.items.values()) if ((it.seenAtMs ?? -1) >= since) heard++;
1699
+ if (!heard) break;
1700
+ for (const [key, it] of [...this.items.entries()]) {
1701
+ if ((it.seenAtMs ?? -1) < since) this.remove(key);
1702
+ }
1703
+ break;
1704
+ }
1671
1705
  case OP_EVENT.attentionAck: {
1672
1706
  const d = e.data;
1673
1707
  if (d?.id) this.acked.add(d.id);
@@ -1812,6 +1846,21 @@ var ObservedReducer = class {
1812
1846
  }
1813
1847
  if (ev.action === "DELETE") {
1814
1848
  for (const epc of ev.epcList) this.remove(epc);
1849
+ const goneAt = ev.readPoint?.id;
1850
+ for (const qe of ev.quantityList ?? []) {
1851
+ if (!qe?.epcClass) continue;
1852
+ if (goneAt) {
1853
+ const key = `${qe.epcClass}@${goneAt}`;
1854
+ if (envelope && this.stale(`item:${key}`, envelope)) continue;
1855
+ this.remove(key);
1856
+ continue;
1857
+ }
1858
+ for (const key of [...this.items.keys()]) {
1859
+ if (!key.startsWith(`${qe.epcClass}@`)) continue;
1860
+ if (envelope && this.stale(`item:${key}`, envelope)) continue;
1861
+ this.remove(key);
1862
+ }
1863
+ }
1815
1864
  return;
1816
1865
  }
1817
1866
  const loc = ev.readPoint?.id;
@@ -1820,7 +1869,9 @@ var ObservedReducer = class {
1820
1869
  this.touchLocation(loc);
1821
1870
  for (const epc of ev.epcList) {
1822
1871
  if (envelope && this.stale(`item:${epc}`, envelope)) continue;
1823
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all));
1872
+ const seen = this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all);
1873
+ const atSeen = Date.parse(String(envelope?.eventTime ?? ""));
1874
+ this.items.set(itemKeyOf(seen), Number.isFinite(atSeen) ? { ...seen, seenAtMs: atSeen } : seen);
1824
1875
  }
1825
1876
  if (!ev.epcList?.length) {
1826
1877
  for (const qe of ev.quantityList ?? []) {
@@ -1828,7 +1879,9 @@ var ObservedReducer = class {
1828
1879
  const mine = all.filter((x) => x.epcClass === qe.epcClass);
1829
1880
  const subLotId = `${qe.epcClass}@${loc}`;
1830
1881
  if (envelope && this.stale(`item:${subLotId}`, envelope)) continue;
1831
- this.items.set(subLotId, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId));
1882
+ const seenQty = this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId);
1883
+ const atQty = Date.parse(String(envelope?.eventTime ?? ""));
1884
+ this.items.set(subLotId, Number.isFinite(atQty) ? { ...seenQty, seenAtMs: atQty } : seenQty);
1832
1885
  }
1833
1886
  }
1834
1887
  }
@@ -2065,8 +2118,16 @@ var ObservedReducer = class {
2065
2118
  origin: n.origin
2066
2119
  };
2067
2120
  }),
2068
- /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
2069
- items: [...this.items.values()].map((i) => ({ ...i })),
2121
+ /*
2122
+ * 갖고 있는 것을 전부 내보낸다 — 줄이면 그 자리에서 정보가 사라진다.
2123
+ *
2124
+ * **`seenAtMs` 는 뺀다.** 그것은 물품에 대한 사실이 아니라 **우리가 언제 들었나**이고, 「이 목록이
2125
+ * 전부다」를 받았을 때 무엇을 지울지 가리는 데만 쓴다. 화면이 알 필요가 없고, 상태에 내보내면
2126
+ * 자체 구동에는 없는 값이라 두 구동이 어긋난다(적합성 검사가 그것을 잡았다).
2127
+ *
2128
+ * 중간 저장본에는 남는다 — 재기동 뒤에도 무엇을 지울지 가릴 수 있어야 한다.
2129
+ */
2130
+ items: [...this.items.values()].map(({ seenAtMs: _seen, ...i }) => ({ ...i })),
2070
2131
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
2071
2132
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
2072
2133
  persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
@@ -9044,6 +9105,18 @@ var SPECS = {
9044
9105
  * `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
9045
9106
  * 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
9046
9107
  */
9108
+ /*
9109
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 알린다.
9110
+ *
9111
+ * 둘 다 요구한다: 어느 목록인지와 그 주기를 **시작한** 시각. 시각이 없으면 무엇을 지울지 가릴 수
9112
+ * 없고, 그때 지우면 전부 지운다 — 받지 않는 것이 옳다.
9113
+ */
9114
+ complete: {
9115
+ eventType: OP_EVENT.complete,
9116
+ identity: "completeAxis",
9117
+ required: ["completeAxis", "since"],
9118
+ fields: { completeAxis: "string", since: "string", recordTime: "string" }
9119
+ },
9047
9120
  observation: {
9048
9121
  eventType: OP_EVENT.observation,
9049
9122
  identity: "locationId",
@@ -9075,6 +9148,7 @@ function operationalKindOf(record) {
9075
9148
  if (has("orderId")) return "order";
9076
9149
  if (has("testableObjectId")) return "test";
9077
9150
  if (has("locationId") && has("propertyId")) return "observation";
9151
+ if (has("completeAxis")) return "complete";
9078
9152
  return void 0;
9079
9153
  }
9080
9154
  function isOperationalRecord(record) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.62",
3
+ "version": "0.7.63",
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": {