@things-factory/headless-twin 10.0.17 → 10.0.19

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.
Files changed (156) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +12 -85
  2. package/dist-server/engine/canonical-ingest.js +92 -26
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/energy-topology.js +3 -3
  5. package/dist-server/engine/energy-topology.js.map +1 -1
  6. package/dist-server/engine/kpi-fold.d.ts +91 -3
  7. package/dist-server/engine/kpi-fold.js +149 -25
  8. package/dist-server/engine/kpi-fold.js.map +1 -1
  9. package/dist-server/engine/kpi-query.js +99 -11
  10. package/dist-server/engine/kpi-query.js.map +1 -1
  11. package/dist-server/engine/local-declarations.js +6 -5
  12. package/dist-server/engine/local-declarations.js.map +1 -1
  13. package/dist-server/engine/model-vocabulary.js +2 -2
  14. package/dist-server/engine/model-vocabulary.js.map +1 -1
  15. package/dist-server/engine/oee-accumulator.d.ts +2 -1
  16. package/dist-server/engine/oee-accumulator.js +3 -2
  17. package/dist-server/engine/oee-accumulator.js.map +1 -1
  18. package/dist-server/engine/twin-engine.d.ts +72 -2
  19. package/dist-server/engine/twin-engine.js +150 -4
  20. package/dist-server/engine/twin-engine.js.map +1 -1
  21. package/dist-server/routes.js +13 -2
  22. package/dist-server/routes.js.map +1 -1
  23. package/dist-server/service/actuation/command-dispatcher.d.ts +32 -0
  24. package/dist-server/service/actuation/command-dispatcher.js +92 -0
  25. package/dist-server/service/actuation/command-dispatcher.js.map +1 -0
  26. package/dist-server/service/actuation/command-store.d.ts +3 -0
  27. package/dist-server/service/actuation/command-store.js +56 -0
  28. package/dist-server/service/actuation/command-store.js.map +1 -0
  29. package/dist-server/service/actuation/index.d.ts +6 -0
  30. package/dist-server/service/actuation/index.js +11 -0
  31. package/dist-server/service/actuation/index.js.map +1 -0
  32. package/dist-server/service/actuation/twin-command.d.ts +30 -0
  33. package/dist-server/service/actuation/twin-command.js +110 -0
  34. package/dist-server/service/actuation/twin-command.js.map +1 -0
  35. package/dist-server/service/index.d.ts +2 -2
  36. package/dist-server/service/index.js +22 -16
  37. package/dist-server/service/index.js.map +1 -1
  38. package/dist-server/service/reference/hook-contract.d.ts +64 -21
  39. package/dist-server/service/reference/hook-contract.js +134 -22
  40. package/dist-server/service/reference/hook-contract.js.map +1 -1
  41. package/dist-server/service/reference/hook-store.d.ts +3 -0
  42. package/dist-server/service/reference/hook-store.js +28 -0
  43. package/dist-server/service/reference/hook-store.js.map +1 -0
  44. package/dist-server/service/reference/reference-adapter.d.ts +65 -1
  45. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  46. package/dist-server/service/reference/reference-hook.d.ts +42 -6
  47. package/dist-server/service/reference/reference-hook.js +185 -27
  48. package/dist-server/service/reference/reference-hook.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +1 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +11 -2
  52. package/dist-server/service/reference/reference-master.js +1 -1
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +2 -2
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
  59. package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
  60. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
  61. package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
  62. package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
  63. package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
  64. package/dist-server/service/twin-backfill/index.d.ts +2 -0
  65. package/dist-server/service/twin-backfill/index.js +6 -0
  66. package/dist-server/service/twin-backfill/index.js.map +1 -0
  67. package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
  68. package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
  69. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
  70. package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
  71. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  72. package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
  73. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  75. package/dist-server/service/twin-model/name-index.js +1 -1
  76. package/dist-server/service/twin-model/name-index.js.map +1 -1
  77. package/dist-server/service/twin-model/project-structure.js +4 -3
  78. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  79. package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
  80. package/dist-server/service/twin-model/twin-equipment.js +2 -2
  81. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  82. package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
  83. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  84. package/dist-server/service/twin-model/twin-model-query.js +4 -4
  85. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  86. package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
  87. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
  89. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  90. package/dist-shared/entity-delta.js +1 -1
  91. package/dist-shared/entity-delta.js.map +1 -1
  92. package/package.json +7 -6
  93. package/server/engine/canonical-ingest.ts +72 -84
  94. package/server/engine/energy-topology.ts +1 -1
  95. package/server/engine/kpi-fold.ts +219 -36
  96. package/server/engine/kpi-query.ts +97 -4
  97. package/server/engine/local-declarations.ts +2 -1
  98. package/server/engine/model-vocabulary.ts +1 -1
  99. package/server/engine/oee-accumulator.ts +4 -2
  100. package/server/engine/twin-engine.ts +163 -5
  101. package/server/routes.ts +18 -2
  102. package/server/service/actuation/command-dispatcher.ts +130 -0
  103. package/server/service/actuation/command-store.ts +62 -0
  104. package/server/service/actuation/index.ts +8 -0
  105. package/server/service/actuation/twin-command.ts +105 -0
  106. package/server/service/index.ts +6 -0
  107. package/server/service/reference/hook-contract.ts +99 -21
  108. package/server/service/reference/hook-store.ts +28 -0
  109. package/server/service/reference/reference-adapter.ts +67 -2
  110. package/server/service/reference/reference-hook.ts +249 -32
  111. package/server/service/reference/reference-live.ts +8 -2
  112. package/server/service/reference/reference-master.ts +12 -3
  113. package/server/service/reference/reference-resolver.ts +1 -1
  114. package/server/service/reference/template-registry.ts +1 -1
  115. package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
  116. package/server/service/twin-backfill/backfill-shape.ts +86 -0
  117. package/server/service/twin-backfill/index.ts +3 -0
  118. package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
  119. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  120. package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
  121. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
  122. package/server/service/twin-model/name-index.ts +1 -1
  123. package/server/service/twin-model/project-structure.ts +3 -2
  124. package/server/service/twin-model/twin-equipment.ts +14 -2
  125. package/server/service/twin-model/twin-model-item-query.ts +1 -1
  126. package/server/service/twin-model/twin-model-query.ts +2 -2
  127. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  128. package/server/service/twin-space/twin-space-resolver.ts +1 -1
  129. package/shared/entity-delta.ts +1 -1
  130. package/test/actuation-dispatch.test.ts +196 -0
  131. package/test/axis-read.test.ts +4 -3
  132. package/test/backfill-shape.test.ts +89 -0
  133. package/test/broadcast-cost-baseline.test.ts +2 -1
  134. package/test/canonical-ingest-vocabularies.test.ts +82 -3
  135. package/test/capability-mapping.test.ts +1 -1
  136. package/test/contract-layer-guard.test.ts +48 -0
  137. package/test/declaration-reaches-model.test.ts +2 -0
  138. package/test/equipment-identity-reaches.test.ts +57 -0
  139. package/test/fact-scope-wiring.test.ts +96 -0
  140. package/test/generated-not-counted.test.ts +99 -0
  141. package/test/hook-sequence.test.ts +333 -0
  142. package/test/ingest-bench.test.ts +2 -1
  143. package/test/ingest-wiring-guard.test.ts +197 -0
  144. package/test/kpi-fold.test.ts +81 -0
  145. package/test/live-mirror-parity.test.ts +6 -3
  146. package/test/local-declarations.test.ts +3 -2
  147. package/test/master-to-twin.test.ts +2 -2
  148. package/test/oee-accumulator.test.ts +18 -3
  149. package/test/projection-reaches-screen.test.ts +5 -0
  150. package/test/projection-reads-declared.test.ts +109 -0
  151. package/test/property-effects.test.ts +7 -3
  152. package/test/reference-hook.test.ts +114 -23
  153. package/test/scale-twin-bench.test.ts +2 -1
  154. package/test/vocabulary-guard.test.ts +1 -1
  155. package/tsconfig.shared.tsbuildinfo +1 -1
  156. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,86 @@
1
+ /*
2
+ * 지난 기록 채우기의 **판단** — 저장소를 모르는 순수 함수.
3
+ *
4
+ * 무엇을 받고 무엇을 거절하는지가 이 파일에 다 있다. 저장소를 아는 코드(`backfill-period-facts`)는
5
+ * 이 판단을 부르고 쓰기만 한다. 갈라 두는 이유는 이 저장소가 이미 쓰는 방식이다 —
6
+ * 판단은 시험이 데이터베이스 없이 지킬 수 있어야 한다(§`event-type-split-shape`).
7
+ */
8
+
9
+ /** 이 길이 받는 사실 — 마감된 구간을 말하는 것들. */
10
+ export const PERIOD_FACTS: readonly string[] = [
11
+ 'energy.usage.period',
12
+ 'energy.generation.period',
13
+ 'energy.generation.price',
14
+ 'energy.tariff.basis',
15
+ 'energy.bill'
16
+ ]
17
+
18
+ export interface BackfillJudgement {
19
+ /** 저널에 넣어도 되는 것. */
20
+ keep: any[]
21
+ /** 받지 않은 것과 그 이유 — 조용히 버리지 않는다. */
22
+ refused: { record: unknown; reason: string }[]
23
+ }
24
+
25
+ /**
26
+ * 무엇을 채울 수 있나.
27
+ *
28
+ * 세 가지로 거절한다.
29
+ *
30
+ * 마감된 구간이 아니다 상태를 바꾸는 사실을 저널에만 넣으면 상태와 저널이 갈라진다
31
+ * 아직 오지 않은 시각 지난 기록이 아니다
32
+ * 보존보다 오래됐다 넣어도 다음 정리에서 지워진다 — 조용히 사라지느니 거절한다
33
+ */
34
+ export function judgeBackfill(
35
+ envelopes: readonly any[],
36
+ opts: { nowMs: number; retentionDays?: number }
37
+ ): BackfillJudgement {
38
+ const keep: any[] = []
39
+ const refused: { record: unknown; reason: string }[] = []
40
+ const horizonMs = opts.retentionDays === undefined ? undefined : opts.nowMs - opts.retentionDays * 86_400_000
41
+
42
+ for (const env of envelopes) {
43
+ const type = String(env?.eventType ?? '')
44
+ if (!PERIOD_FACTS.includes(type)) {
45
+ refused.push({
46
+ record: env,
47
+ reason:
48
+ `${type} 는 마감된 구간 사실이 아니다 — 이 길은 저널에만 쓰므로, 상태를 바꾸는 사실을 여기로 넣으면 ` +
49
+ '상태와 저널이 갈라진다. 라이브 문으로 보내라'
50
+ })
51
+ continue
52
+ }
53
+ const atMs = Date.parse(String(env?.eventTime ?? ''))
54
+ if (!Number.isFinite(atMs)) {
55
+ refused.push({ record: env, reason: '사건 시각을 읽을 수 없다' })
56
+ continue
57
+ }
58
+ if (atMs > opts.nowMs) {
59
+ refused.push({ record: env, reason: `아직 오지 않은 시각이다(${env?.eventTime}) — 지난 기록이 아니다` })
60
+ continue
61
+ }
62
+ if (horizonMs !== undefined && atMs < horizonMs) {
63
+ refused.push({
64
+ record: env,
65
+ reason:
66
+ `보존 기간(${opts.retentionDays}일)보다 오래됐다(${env?.eventTime}) — 넣어도 다음 정리에서 지워진다. ` +
67
+ '더 뒤까지 보려면 보존 기간을 늘려야 한다'
68
+ })
69
+ continue
70
+ }
71
+ keep.push(env)
72
+ }
73
+
74
+ return { keep, refused }
75
+ }
76
+
77
+ /**
78
+ * 이미 있는 것을 가려낸다 — **정체성은 내용에 있다**(커널 0.7.81).
79
+ *
80
+ * 저장소의 제약으로 막지 않는 이유는 드라이버가 다섯이고 충돌 처리가 제각각이기 때문이다.
81
+ * 구간 단위로 한 번 읽어 가려내는 편이 이식성이 있고 값도 싸다.
82
+ */
83
+ export function splitAlreadyThere(keep: readonly any[], existingIds: ReadonlySet<string>): { fresh: any[]; skipped: number } {
84
+ const fresh = keep.filter(e => typeof e?.eventId === 'string' && e.eventId && !existingIds.has(e.eventId))
85
+ return { fresh, skipped: keep.length - fresh.length }
86
+ }
@@ -0,0 +1,3 @@
1
+ import { TwinBackfillResolver } from './twin-backfill-resolver.js'
2
+
3
+ export const resolvers = [TwinBackfillResolver]
@@ -0,0 +1,35 @@
1
+ import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
2
+ import { ScalarObject } from '@things-factory/shell'
3
+
4
+ import { backfillPeriodFacts } from './backfill-period-facts.js'
5
+
6
+ /*
7
+ * 지난 기록 채우기의 **문** — 커넥터가 트윈이 없던 동안의 날들을 뒤늦게 넣는다.
8
+ *
9
+ * ── 왜 문을 따로 두나 (2026-08-30) ────────────────────────────────────────
10
+ * 라이브 유입과 판단이 다르다. 라이브는 트윈의 「지금」을 바꾸고, 이쪽은 저널에만 쓴다. 같은 문으로
11
+ * 받으면 옛 표본이 가드에 막히거나 트윈의 시계가 과거로 끌린다(§`backfill-period-facts`).
12
+ *
13
+ * 레코드의 **모양은 라이브와 같다** — 커넥터가 다른 어휘를 배울 필요가 없다. 다른 것은 받는 종류뿐이고,
14
+ * 그 판단은 커널의 유입과 `judgeBackfill` 이 한다.
15
+ *
16
+ * ── 왜 이 파일이 늦게 생겼나 ──────────────────────────────────────────────
17
+ * 채우기 자체는 만들어 두고 **부르는 곳을 만들지 않아** 쓸 수 없는 채로 있었다. 8월 29일 발전량을
18
+ * 되살리려다 드러났다. 같은 부류를 그날 두 번 겪었다(커널의 발전 기간 문도 없었다).
19
+ */
20
+ @Resolver()
21
+ export class TwinBackfillResolver {
22
+ /* 트윈의 모든 변경과 같은 게이트다(ADR-0027) — 저널에 사실을 적는 일이므로 변경이다. */
23
+ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)')
24
+ @Mutation(returns => ScalarObject, {
25
+ description:
26
+ 'Backfill closed-period facts into a twin journal without touching its live state. Records use the same shape as live ingest; only closed-period facts are accepted (usage/generation periods, generation price, tariff basis, bill). Point-in-time observations are refused with a reason, as are times beyond journal retention. Re-running is safe: a period already present is skipped, because a closed period is identified by its content. Returns { written, skipped, refused }.'
27
+ })
28
+ async backfillTwinPeriodFacts(
29
+ @Arg('instanceId') instanceId: string,
30
+ @Arg('records', type => [ScalarObject]) records: unknown[],
31
+ @Ctx() context: ResolverContext
32
+ ): Promise<{ written: number; skipped: number; refused: { record: unknown; reason: string }[] }> {
33
+ return await backfillPeriodFacts({ domainId: context.state.domain.id, instanceId, records })
34
+ }
35
+ }
@@ -1,7 +1,7 @@
1
1
  import { twinError } from '../../engine/log.js'
2
2
  import { Arg, Ctx, Mutation, Query, Resolver, Directive } from 'type-graphql'
3
3
 
4
- import { validateScenario } from '@operato/twin-kernel'
4
+ import { validateScenario } from '@operato/ops-contract'
5
5
 
6
6
  import { ScalarObject } from '@things-factory/shell'
7
7
 
@@ -6,7 +6,8 @@
6
6
  * 조합을 재현하면 프로파일 병합(mes.products)이나 능력 메타 포함 여부가 갈린다(방언).
7
7
  */
8
8
  /* 커널은 CJS 배포 — ESM import 대신 require(리졸버의 기존 관행과 동일). */
9
- const { DOMAIN_CATALOG, MES_PRODUCTS, CAPABILITIES, capabilitiesForType } = require('@operato/twin-kernel')
9
+ const { MES_PRODUCTS } = require('@operato/twin-kernel')
10
+ const { DOMAIN_CATALOG, CAPABILITIES, capabilitiesForType } = require('@operato/ops-contract')
10
11
 
11
12
  /**
12
13
  * 시스템별(wms/yms/mes) 노드·설비 타입과 각 타입의 능력을 담은 카탈로그.
@@ -7,7 +7,7 @@ import { TwinEngine } from '../../engine/index.js'
7
7
  import { readRestartPolicy } from '../../engine/restart-policy.js'
8
8
  import { TwinInstance } from '../twin-instance/twin-instance.js'
9
9
 
10
- import type { TwinModelDef } from '@operato/twin-kernel'
10
+ import type { TwinModelDef } from '@operato/ops-contract'
11
11
 
12
12
  /* 도메인 어휘 카탈로그(시스템별 노드 타입) — 커널이 SSOT. 앱/UI 는 이걸 소싱하고 재선언하지 않는다(방언 금지).
13
13
  * 조합은 domain-catalog.ts 가 소유 — 리졸버와 서버측 소비자(board-ai 도구 등)가 같은 형태를 본다. */
@@ -6,7 +6,7 @@
6
6
  * 두 벌을 만들면 한쪽만 고쳐지므로 한 곳에 둔다(재발명 금지).
7
7
  */
8
8
  /* 커널 런타임 로드 — 어느 축이 있는지는 카탈로그가 선언한다(이 파일은 도메인 모양을 알지 않는다). */
9
- const { TWIN_AXES, axisSource } = require('@operato/twin-kernel')
9
+ const { TWIN_AXES, axisSource } = require('@operato/ops-contract')
10
10
 
11
11
  /** `productionSpec.definition.recipes` 같은 경로를 문서에서 따라간다. */
12
12
  export const at = (o: any, path: string) => path.split('.').reduce((a: any, k: string) => (a == null ? undefined : a[k]), o)
@@ -5,7 +5,7 @@ import { TwinArea } from '../twin-space/twin-area.js'
5
5
  import { TwinLocation } from './twin-location.js'
6
6
  import { TwinEquipment } from './twin-equipment.js'
7
7
  import { TwinOperation } from './twin-operation.js'
8
- import { readBoardEquipment } from '@operato/twin-kernel'
8
+ import { readBoardEquipment } from '@operato/ops-contract'
9
9
 
10
10
  /*
11
11
  * 구조 투영 — `model`(커널 입력 문서) → **표준 엔티티 행**(ADR-0032).
@@ -149,7 +149,8 @@ export async function projectStructure(domainId: string, instanceId: string, mod
149
149
  homeLocation: home ? ({ id: home.id } as any) : null,
150
150
  mtbfMs: m.mtbfMs ?? null,
151
151
  mttrMs: m.mttrMs ?? null,
152
- gs1Id: m.gs1Id ?? null,
152
+ /* 선언된 정체성 — 예전에는 선언에 없는 `gs1Id` 를 읽어 이 칸이 늘 비어 있었다. */
153
+ identity: (m as any).identity ?? null,
153
154
  sourceRef,
154
155
  ingestedAt
155
156
  } as any)
@@ -127,9 +127,21 @@ export class TwinEquipment {
127
127
  @Field(type => Int, { nullable: true, description: 'Declared mean time to repair in ms.' })
128
128
  mttrMs?: number
129
129
 
130
+ /*
131
+ * ── **선언된 설비 정체성** (2026-08-30) ───────────────────────────────────
132
+ *
133
+ * 예전 이름은 `gs1Id` 였고, 투영이 선언의 `m.gs1Id` 를 읽었다. **그런 필드가 선언에 없다** — 그래서
134
+ * 이 칸은 일곱 대 모두 영원히 비어 있었다. 자리는 있고 길이 없었다.
135
+ *
136
+ * 그 사이 커널에 `TwinModelDef.equipment[].identity` 를 열었으므로 어휘가 두 벌이 됐다. 하나로
137
+ * 합친다. 이름을 `identity` 로 두는 이유는 **GS1 키가 아닌 정체성이 정상**이기 때문이다 —
138
+ * 실측한 값은 인버터의 하드웨어 식별자(`ES100000007d8d061d-1`)이고 GS1 키가 아니다.
139
+ *
140
+ * 자리(`twin_location`)의 `gs1Id` 는 그대로 둔다. 그쪽은 실제로 SGLN 이 온다.
141
+ */
130
142
  @Column({ nullable: true })
131
- @Field({ nullable: true, description: 'GS1 identifier supplied by the source (e.g. GIAI).' })
132
- gs1Id?: string
143
+ @Field({ nullable: true, description: 'Declared global identity of this equipment (TwinModelDef.equipment[].identity). Not necessarily a GS1 key.' })
144
+ identity?: string
133
145
 
134
146
  /* ── 캐시라는 사실을 구조로 (ADR-0032-D) ── */
135
147
  @Column()
@@ -17,7 +17,7 @@ import { TwinArea } from '../twin-space/twin-area.js'
17
17
  import { at, idOf, nameIndex, namesFor } from './name-index.js'
18
18
 
19
19
  /* 커널 런타임 로드 — 축·관계·속성 선언의 SSOT. 이 파일은 도메인 모양을 알지 않는다. */
20
- const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, documentPath, relationsFrom, relationsTo, propertiesOf } = require('@operato/twin-kernel')
20
+ const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, documentPath, relationsFrom, relationsTo, propertiesOf } = require('@operato/ops-contract')
21
21
 
22
22
  /**
23
23
  * 축 → 그 축이 이미 **행으로 옮겨졌나**, 그리고 행의 어느 컬럼이 id 인가.
@@ -8,7 +8,7 @@ import { ScalarObject, getRepository } from '@things-factory/shell'
8
8
 
9
9
  import { TwinEngine } from '../../engine/index.js'
10
10
  import { modelGapOf } from '../../engine/model-gap.js'
11
- import { readBoardEquipment } from '@operato/twin-kernel'
11
+ import { readBoardEquipment } from '@operato/ops-contract'
12
12
  import { TwinInstance } from '../twin-instance/twin-instance.js'
13
13
  import { TwinSpace } from '../twin-space/twin-space.js'
14
14
  import { TwinReference } from '../reference/twin-reference.js'
@@ -45,7 +45,7 @@ import { unwrapState } from '../../engine/warm-start.js'
45
45
  import { axisValueOf } from '@things-factory/headless-twin/dist-shared/axis-read.js'
46
46
 
47
47
  /* 커널 런타임 로드 — CJS 번들(엔진과 같은 방식). 축·관계 선언의 SSOT 다. */
48
- const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, axisAppliesTo } = require('@operato/twin-kernel')
48
+ const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, axisAppliesTo } = require('@operato/ops-contract')
49
49
 
50
50
  /**
51
51
  * 축 → 그 축이 이미 **행으로 옮겨졌나**. 옮겨진 축은 DB 가 세고, 아직인 축은 문서를 세어야 한다.
@@ -5,7 +5,7 @@ import { ScalarObject, getRepository } from '@things-factory/shell'
5
5
 
6
6
  import { TwinInstance } from '../twin-instance/twin-instance.js'
7
7
 
8
- const { TWIN_RELATIONS, axisInfo } = require('@operato/twin-kernel')
8
+ const { TWIN_RELATIONS, axisInfo } = require('@operato/ops-contract')
9
9
 
10
10
  /**
11
11
  * 재귀 전개 — **자기 축으로 돌아오는 선언을 따라간다.**
@@ -486,7 +486,7 @@ export class TwinSpaceResolver {
486
486
  polygonRefs += polys.filter(p => (p.binding as any)?.groupId === areaId || (p.binding as any)?.areaId === areaId).length
487
487
  }
488
488
  if (children > 0 || polygonRefs > 0) {
489
- throw new Error(`area "${areaId}" 는 참조가 남아 삭제할 수 없습니다 (자식 ${children}, 폴리곤 ${polygonRefs}). 참조 제거 후 삭제하세요.`)
489
+ throw new Error(`deleteArea: area "${areaId}" cannot be deleted due to existing references (children: ${children}, polygonRefs: ${polygonRefs}). Remove references first.`)
490
490
  }
491
491
  await getRepository(TwinArea).delete({ domain: { id: domainId } as any, space: { id: space.id } as any, areaId })
492
492
  return true
@@ -50,7 +50,7 @@ export const ENERGY_TAG_PREFIX = '__energy__'
50
50
 
51
51
  /** 이 트윈의 집약 채널 태그. 트윈 식별자가 없으면 만들지 않는다 — 덮어쓰기의 원인이었다. */
52
52
  export function aggregateTag(prefix: string, instanceId: string): string {
53
- if (!instanceId) throw new Error('aggregateTag: instanceId required — 트윈 구분이 없는 태그는 다른 트윈의 것을 덮는다')
53
+ if (!instanceId) throw new Error('aggregateTag: instanceId required — tags without instanceId overwrite other twin states across tenants')
54
54
 
55
55
  return `${prefix}:${instanceId}`
56
56
  }
@@ -0,0 +1,196 @@
1
+ /*
2
+ * **승인을 지나지 않은 조치는 현장에 나가지 못한다** — 배선.
3
+ *
4
+ * ── 왜 배선을 따로 보나 (2026-08-31) ─────────────────────────────────────
5
+ * 승인 판정 자체는 계약이 지킨다(`ops-contract/test/actuation-gate.test.ts`). 여기서 보는 것은
6
+ * **그 판정이 실제 경로에 서 있는가**다 — 어댑터가 불렸는가, 상태가 적혔는가, 다시 세운 뒤에도 승인이
7
+ * 남아 있는가.
8
+ *
9
+ * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 게이트가 그 부류로 나면 결과가
10
+ * 현장의 작업지시다. 그래서 마지막 시험이 **저장본만 남기고 다시 세운다.**
11
+ */
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+
15
+ import type { TwinCommand } from '@operato/ops-contract'
16
+
17
+ import { approve, dispatch, reject, type CommandAck, type CommandStore } from '../server/service/actuation/command-dispatcher.js'
18
+
19
+ const DOMAIN = 'd-1'
20
+ const AT = '2026-08-31T02:00:00.000Z'
21
+ const APPROVAL = { by: 'line-lead', at: AT }
22
+
23
+ const irreversible = (over: Partial<TwinCommand> = {}): TwinCommand => ({
24
+ id: 'cmd-1',
25
+ instanceId: 'twin-a',
26
+ type: 'order.hold',
27
+ origin: 'ai',
28
+ proposedAt: AT,
29
+ state: 'proposed',
30
+ ...over
31
+ })
32
+
33
+ /** 저장본 하나 — 프로세스가 아니라 여기 산다. 그래서 다시 세워도 남는다. */
34
+ function makeStore(initial: TwinCommand): CommandStore & { rows: Map<string, TwinCommand> } {
35
+ const rows = new Map<string, TwinCommand>([[initial.id, initial]])
36
+ return {
37
+ rows,
38
+ load: async (domainId, id) => (domainId === DOMAIN ? (rows.get(id) ?? null) : null),
39
+ /* 저장소를 흉내 내는 것이라 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */
40
+ save: async (domainId, command) => {
41
+ assert.equal(domainId, DOMAIN)
42
+ rows.set(command.id, JSON.parse(JSON.stringify(command)))
43
+ }
44
+ }
45
+ }
46
+
47
+ const ok = (ref = 'JOB-1') => async (): Promise<CommandAck> => ({ ok: true, ref })
48
+
49
+ /* ── 승인 없이는 나가지 못한다 ────────────────────────────────────────────── */
50
+
51
+ test('★ 승인하지 않은 조치는 어댑터에 닿지 않는다', async () => {
52
+ const store = makeStore(irreversible())
53
+ let called = false
54
+
55
+ await assert.rejects(
56
+ dispatch(store, DOMAIN, 'cmd-1', async () => {
57
+ called = true
58
+ return { ok: true }
59
+ }),
60
+ /승인되지 않은/
61
+ )
62
+ assert.equal(called, false, '어댑터가 불렸다 — 게이트가 서지 않았다')
63
+ assert.equal(store.rows.get('cmd-1')!.state, 'proposed', '상태가 움직였다')
64
+ })
65
+
66
+ test('★ 상태만 approved 로 적고 승인 기록이 없으면 막는다 — 저장본을 손으로 고쳐도 열리지 않는다', async () => {
67
+ const store = makeStore(irreversible({ state: 'approved' }))
68
+ let called = false
69
+ await assert.rejects(
70
+ dispatch(store, DOMAIN, 'cmd-1', async () => {
71
+ called = true
72
+ return { ok: true }
73
+ }),
74
+ /승인 기록이 없는/
75
+ )
76
+ assert.equal(called, false)
77
+ })
78
+
79
+ test('★ 승인하면 나간다 — 어댑터가 돌려준 것이 남는다', async () => {
80
+ const store = makeStore(irreversible())
81
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
82
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-77'))
83
+
84
+ assert.equal(settled.state, 'acked')
85
+ assert.equal(settled.dispatchRef, 'JOB-77', '되돌릴 때 무엇을 되돌릴지 말할 수 없게 된다')
86
+ assert.equal(store.rows.get('cmd-1')!.approval?.by, 'line-lead')
87
+ })
88
+
89
+ test('★ 넘기기 전에 「넘기는 중」을 먼저 적는다 — 그 사이에 죽으면 현장이 두 번 움직인다', async () => {
90
+ const store = makeStore(irreversible())
91
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
92
+
93
+ let seenWhileDispatching: string | undefined
94
+ await dispatch(store, DOMAIN, 'cmd-1', async () => {
95
+ seenWhileDispatching = store.rows.get('cmd-1')!.state
96
+ return { ok: true, ref: 'x' }
97
+ })
98
+ assert.equal(seenWhileDispatching, 'dispatched', '어댑터를 부르는 동안 저장본이 아직 approved 였다')
99
+ })
100
+
101
+ /* ── 되돌릴 수 있는 것의 다른 문 ─────────────────────────────────────────── */
102
+
103
+ test('★ 되돌릴 수 있는 조치는 사람 없이 지난다 — 시뮬과 what-if 가 이 길이다', async () => {
104
+ const store = makeStore(irreversible({ reversible: true }))
105
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', ok())
106
+ assert.equal(settled.state, 'acked')
107
+ })
108
+
109
+ test('★ 되돌릴 수 있다고 적지 않은 것은 그 문으로 못 간다 — 모름을 통과로 읽지 않는다', async () => {
110
+ const store = makeStore(irreversible({ reversible: false }))
111
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()))
112
+ })
113
+
114
+ /* ── 실패와 되풀이 ────────────────────────────────────────────────────────── */
115
+
116
+ test('★ 어댑터가 던지면 실패로 남고 이유가 적힌다', async () => {
117
+ const store = makeStore(irreversible())
118
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
119
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => {
120
+ throw new Error('MES 가 응답하지 않는다')
121
+ })
122
+
123
+ assert.equal(settled.state, 'failed')
124
+ assert.match(String(settled.error), /응답하지 않는다/)
125
+ })
126
+
127
+ test('어댑터가 이유 없이 실패해도 빈 칸으로 두지 않는다', async () => {
128
+ const store = makeStore(irreversible())
129
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
130
+ const settled = await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false }))
131
+ assert.ok(String(settled.error).length > 0)
132
+ })
133
+
134
+ test('★ 실패한 것은 다시 넘길 수 있다 — 승인을 다시 받지 않는다', async () => {
135
+ const store = makeStore(irreversible())
136
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
137
+ await dispatch(store, DOMAIN, 'cmd-1', async () => ({ ok: false, error: '잠시 끊겼다' }))
138
+
139
+ const retried = await dispatch(store, DOMAIN, 'cmd-1', ok('JOB-9'))
140
+ assert.equal(retried.state, 'acked')
141
+ assert.equal(retried.dispatchRef, 'JOB-9')
142
+ })
143
+
144
+ test('★ 이미 답을 받은 것은 다시 나가지 않는다 — 두 번 나가면 현장이 두 번 움직인다', async () => {
145
+ const store = makeStore(irreversible())
146
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
147
+ await dispatch(store, DOMAIN, 'cmd-1', ok())
148
+
149
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /넘길 수 없다/)
150
+ })
151
+
152
+ /* ── 거절 ─────────────────────────────────────────────────────────────────── */
153
+
154
+ test('★ 거절하면 끝이다 — 되살아나지 않는다', async () => {
155
+ const store = makeStore(irreversible())
156
+ await reject(store, DOMAIN, 'cmd-1', { by: 'line-lead', at: AT, note: '지금 그 라인은 정지 중' })
157
+
158
+ assert.equal(store.rows.get('cmd-1')!.state, 'rejected')
159
+ await assert.rejects(approve(store, DOMAIN, 'cmd-1', APPROVAL))
160
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()))
161
+ })
162
+
163
+ test('승인을 기다리는 중에도 거절할 수 있다', async () => {
164
+ const store = makeStore(irreversible())
165
+ await approve(store, DOMAIN, 'cmd-1', APPROVAL)
166
+ await reject(store, DOMAIN, 'cmd-1', { by: 'plant-manager', at: AT })
167
+ assert.equal(store.rows.get('cmd-1')!.state, 'rejected')
168
+ })
169
+
170
+ /* ── 재기동 ───────────────────────────────────────────────────────────────── */
171
+
172
+ test('★ 승인이 재기동을 넘어 산다 — 사람이 승인한 것이 사라지면 안 된다', async () => {
173
+ const first = makeStore(irreversible())
174
+ await approve(first, DOMAIN, 'cmd-1', APPROVAL)
175
+ const saved: TwinCommand = JSON.parse(JSON.stringify(first.rows.get('cmd-1')))
176
+
177
+ /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */
178
+ const second = makeStore(saved)
179
+ const settled = await dispatch(second, DOMAIN, 'cmd-1', ok('JOB-3'))
180
+
181
+ assert.equal(settled.state, 'acked', '이어받지 못하면 승인이 사라져 던진다')
182
+ assert.equal(settled.approval?.by, 'line-lead')
183
+ })
184
+
185
+ test('★ 재기동이 게이트를 열지 않는다 — 되세운 것도 같은 문을 지난다', async () => {
186
+ /* JSON 에는 승인 표식이 없다. 되세울 때 표식을 그냥 붙이면 재기동이 곧 우회로가 된다. */
187
+ const saved: TwinCommand = JSON.parse(JSON.stringify(irreversible()))
188
+ const store = makeStore(saved)
189
+ await assert.rejects(dispatch(store, DOMAIN, 'cmd-1', ok()), /승인되지 않은/)
190
+ })
191
+
192
+ test('모르는 커맨드는 조용히 넘어가지 않는다', async () => {
193
+ const store = makeStore(irreversible())
194
+ await assert.rejects(dispatch(store, DOMAIN, 'nope', ok()), /모르는 커맨드/)
195
+ await assert.rejects(approve(store, DOMAIN, 'nope', APPROVAL), /모르는 커맨드/)
196
+ })
@@ -51,7 +51,8 @@ test('커널 선언과 상태의 모양이 맞는다 — 선언한 경로에 정
51
51
  * 선언(`path`)과 실제 상태의 모양이 어긋나면 이 배선은 알리지 않고 빈 카드를 만든다. 그래서 **커널이 낸
52
52
  * 상태로** 확인한다 — 선언을 읽는 쪽과 값을 만드는 쪽이 같은 모양을 쓰는지 보는 것이 요점이다.
53
53
  */
54
- const { EmsKernel, axisInfo, ENERGY_EVENT, DEMAND_WINDOW_MS } = await import('@operato/twin-kernel')
54
+ const { EmsKernel, DEMAND_WINDOW_MS } = await import('@operato/twin-kernel')
55
+ const { axisInfo, ENERGY_EVENT } = await import('@operato/ops-contract')
55
56
  const info = axisInfo('demandWindows')!
56
57
  assert.ok(info, '커널이 그 축을 선언해야 한다')
57
58
  assert.equal(info.path, 'energy.closed')
@@ -134,7 +135,7 @@ test('축을 이름으로 읽는 자리가 없다 — 선언된 경로를 지나
134
135
  test('수요 구간은 실제로 중첩된 축이다 — 이 가드가 지키는 대상이 있다', () => {
135
136
  /* 가드가 지킬 것이 없으면 그 가드는 초록으로 거짓을 말한다. 대상이 실재하는지 함께 본다. */
136
137
  const req = createRequire(import.meta.url)
137
- const { TWIN_AXES } = req('@operato/twin-kernel')
138
+ const { TWIN_AXES } = req('@operato/ops-contract')
138
139
  const nested = (TWIN_AXES as any[]).filter(a => a.path)
139
140
 
140
141
  assert.ok(nested.length > 0, '경로를 가진 축이 하나도 없으면 이 가드는 의미가 없다')
@@ -156,7 +157,7 @@ test('수요 구간은 실제로 중첩된 축이다 — 이 가드가 지키는
156
157
  */
157
158
  test('id 가 없는 상태 축은 무엇이 항목을 가리키는지 선언한다', () => {
158
159
  const req2 = createRequire(import.meta.url)
159
- const { TWIN_AXES } = req2('@operato/twin-kernel')
160
+ const { TWIN_AXES } = req2('@operato/ops-contract')
160
161
  const dw = (TWIN_AXES as any[]).find(a => a.axis === 'demandWindows')
161
162
 
162
163
  assert.ok(dw, '수요 구간 축이 있어야 한다')
@@ -0,0 +1,89 @@
1
+ /*
2
+ * 지난 기록 채우기의 판단 — **무엇을 받고 무엇을 거절하나.**
3
+ *
4
+ * ── 왜 저널에만 쓰나 (2026-08-30) ──────────────────────────────────────────
5
+ * 커널의 상태는 「지금」이다. 늦게 온 옛 표본은 가드가 막는다(그렇게 만들었다). 지난 기록을 라이브
6
+ * 문으로 넣으면 가드에 막히거나 트윈의 시계가 과거로 끌린다. 그래서 이 길은 저널에만 쓰고,
7
+ * **상태를 바꾸는 사실은 아예 받지 않는다.**
8
+ *
9
+ * ── 보존 안에서만 (사용자 결정) ────────────────────────────────────────────
10
+ * 보존보다 오래된 것을 채우면 다음 정리에서 지워진다. 조용히 넣고 지워지면 아무도 모르므로
11
+ * 거절하고 이유를 말한다.
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+
16
+ import { judgeBackfill, splitAlreadyThere, PERIOD_FACTS } from '../server/service/twin-backfill/backfill-shape.ts'
17
+
18
+ const NOW = Date.parse('2026-08-30T00:00:00.000Z')
19
+ const env = (eventType: string, eventTime: string, eventId = `${eventType}|${eventTime}`) => ({ eventType, eventTime, eventId })
20
+
21
+ test('★ 마감된 구간 사실을 받는다', () => {
22
+ const r = judgeBackfill([env('energy.usage.period', '2026-08-29T00:00:00.000Z')], { nowMs: NOW, retentionDays: 7 })
23
+ assert.equal(r.keep.length, 1)
24
+ assert.equal(r.refused.length, 0)
25
+ })
26
+
27
+ test('★ 상태를 바꾸는 사실은 받지 않는다 — 저널에만 넣으면 상태와 저널이 갈라진다', () => {
28
+ const r = judgeBackfill(
29
+ [env('energy.measured', '2026-08-29T00:00:00.000Z'), env('energy.generated', '2026-08-29T00:00:00.000Z')],
30
+ { nowMs: NOW, retentionDays: 7 }
31
+ )
32
+ assert.equal(r.keep.length, 0)
33
+ assert.equal(r.refused.length, 2)
34
+ assert.match(r.refused[0].reason, /라이브 문으로 보내라/)
35
+ })
36
+
37
+ test('★ 보존보다 오래된 것은 거절하고 이유를 말한다 — 넣어도 지워진다', () => {
38
+ const r = judgeBackfill([env('energy.usage.period', '2026-08-01T00:00:00.000Z')], { nowMs: NOW, retentionDays: 7 })
39
+ assert.equal(r.keep.length, 0)
40
+ assert.match(r.refused[0].reason, /보존 기간\(7일\)보다 오래됐다/)
41
+ assert.match(r.refused[0].reason, /보존 기간을 늘려야 한다/, '무엇을 해야 하는지 함께 말한다')
42
+ })
43
+
44
+ test('보존 안쪽 경계는 받는다', () => {
45
+ const justInside = new Date(NOW - 7 * 86_400_000 + 1000).toISOString()
46
+ const r = judgeBackfill([env('energy.usage.period', justInside)], { nowMs: NOW, retentionDays: 7 })
47
+ assert.equal(r.keep.length, 1)
48
+ })
49
+
50
+ test('보존 정책이 없으면 오래됨을 판정하지 않는다 — 없는 기준으로 거절하지 않는다', () => {
51
+ const r = judgeBackfill([env('energy.usage.period', '2020-01-01T00:00:00.000Z')], { nowMs: NOW })
52
+ assert.equal(r.keep.length, 1)
53
+ })
54
+
55
+ test('★ 아직 오지 않은 시각은 거절한다 — 지난 기록이 아니다', () => {
56
+ const r = judgeBackfill([env('energy.usage.period', '2026-09-01T00:00:00.000Z')], { nowMs: NOW, retentionDays: 7 })
57
+ assert.equal(r.keep.length, 0)
58
+ assert.match(r.refused[0].reason, /아직 오지 않은 시각/)
59
+ })
60
+
61
+ test('시각을 읽을 수 없으면 거절한다', () => {
62
+ const r = judgeBackfill([env('energy.usage.period', 'n/a')], { nowMs: NOW, retentionDays: 7 })
63
+ assert.equal(r.keep.length, 0)
64
+ assert.match(r.refused[0].reason, /읽을 수 없다/)
65
+ })
66
+
67
+ test('받는 종류 다섯이 모두 마감된 구간 사실이다', () => {
68
+ for (const t of PERIOD_FACTS) {
69
+ const r = judgeBackfill([env(t, '2026-08-29T00:00:00.000Z')], { nowMs: NOW, retentionDays: 7 })
70
+ assert.equal(r.keep.length, 1, `${t} 를 받아야 한다`)
71
+ }
72
+ })
73
+
74
+ /* ── 같은 기간을 두 번 넣지 않는다 ────────────────────────────────────────── */
75
+
76
+ test('★ 이미 있는 것은 건너뛴다 — 다시 읽어도 중복이 쌓이지 않는다', () => {
77
+ const a = env('energy.usage.period', '2026-08-29T00:00:00.000Z')
78
+ const b = env('energy.usage.period', '2026-08-29T01:00:00.000Z')
79
+ const r = splitAlreadyThere([a, b], new Set([a.eventId]))
80
+ assert.equal(r.fresh.length, 1)
81
+ assert.equal(r.fresh[0].eventId, b.eventId)
82
+ assert.equal(r.skipped, 1)
83
+ })
84
+
85
+ test('id 가 없으면 넣지 않는다 — 같은 사실인지 판단할 근거가 없다', () => {
86
+ const r = splitAlreadyThere([{ eventType: 'energy.usage.period', eventTime: '2026-08-29T00:00:00.000Z' }], new Set())
87
+ assert.equal(r.fresh.length, 0)
88
+ assert.equal(r.skipped, 1)
89
+ })
@@ -22,7 +22,8 @@
22
22
  import { test } from 'node:test'
23
23
  import assert from 'node:assert/strict'
24
24
 
25
- import { MesKernel, type CanonicalEnvelope, type ProductionSpec, type ScenarioDef, type TwinModelDef } from '@operato/twin-kernel'
25
+ import { MesKernel } from '@operato/twin-kernel'
26
+ import { type CanonicalEnvelope, type ProductionSpec, type ScenarioDef, type TwinModelDef } from '@operato/ops-contract'
26
27
 
27
28
  import { buildEntityDeltas } from '../shared/entity-delta.ts'
28
29