@things-factory/headless-twin 10.1.3 → 10.1.5
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.
- package/dist-server/engine/canonical-ingest.js +48 -3
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/ingest-dedupe.d.ts +1 -50
- package/dist-server/engine/ingest-dedupe.js +24 -327
- package/dist-server/engine/ingest-dedupe.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +1 -550
- package/dist-server/engine/ingest-health.js +20 -663
- package/dist-server/engine/ingest-health.js.map +1 -1
- package/dist-server/engine/twin-engine.js +14 -2
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/service/reference/twin-reference.d.ts +24 -0
- package/dist-server/service/reference/twin-reference.js +4 -3
- package/dist-server/service/reference/twin-reference.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +21 -1
- package/dist-server/service/twin-event/twin-event-keys.js +29 -2
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +35 -0
- package/dist-server/service/twin-event/twin-event.js +5 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/package.json +8 -7
- package/server/engine/canonical-ingest.ts +52 -3
- package/server/engine/ingest-dedupe.ts +22 -323
- package/server/engine/ingest-health.ts +18 -1075
- package/server/engine/twin-engine.ts +14 -2
- package/server/service/reference/twin-reference.ts +29 -4
- package/server/service/twin-event/twin-event-keys.ts +31 -2
- package/server/service/twin-event/twin-event.ts +38 -0
- package/test/control-capability.test.ts +6 -11
- package/test/dispatch-after-approval-source.test.ts +81 -0
- package/test/guard-roots.ts +76 -0
- package/test/ingest-health-wiring.test.ts +3 -4
- package/test/journal-read-discipline.test.ts +3 -4
- package/test/kernel-kind-guard.test.ts +3 -1
- package/test/provenance-promotion.test.ts +99 -0
- package/test/rejection-carries-event-id.test.ts +101 -0
- package/test/secrets-at-rest.test.ts +135 -0
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -8,6 +8,30 @@ export declare class TwinReference {
|
|
|
8
8
|
siteName?: string;
|
|
9
9
|
description?: string;
|
|
10
10
|
adapterType: string;
|
|
11
|
+
/**
|
|
12
|
+
* 이 연결에 필요한 값 — **자격 증명이 여기 들어온다. 그래서 저장될 때 암호화된다.**
|
|
13
|
+
*
|
|
14
|
+
* ── 왜 표에 두나 (2026-09-09) ─────────────────────────────────────────────
|
|
15
|
+
* 비밀값은 환경에 두고 이름으로 고르는 것이 원칙이다(§`webhookSecretCandidates`). 그 원칙은
|
|
16
|
+
* **설치본마다 하나인 키**에 맞는다 — 배포할 때 넣으면 된다.
|
|
17
|
+
*
|
|
18
|
+
* 트윈의 연결은 그 모양이 아니다. **사람이 화면에서 연결을 만든다.** 새 공장을 붙이려고 재배포할
|
|
19
|
+
* 수는 없으므로, 연결마다 다른 이 값은 표에 있어야 한다. 그 자리에서 원칙이 갈린다:
|
|
20
|
+
*
|
|
21
|
+
* 설치본마다 하나인 키 환경변수. 이름이 어느 상대 것인지 말한다
|
|
22
|
+
* 연결마다 다른 값 표. 대신 저장될 때 암호화한다
|
|
23
|
+
*
|
|
24
|
+
* ── 무엇을 지키고 무엇을 못 지키나 ────────────────────────────────────────
|
|
25
|
+
* 저장된 것을 지킨다 — DB 파일 · 백업 · 복제본. 2026-09-09 에 이 칸에서 `hookSecret` 과 접속
|
|
26
|
+
* 토큰을 명령 두 개로 읽었다.
|
|
27
|
+
*
|
|
28
|
+
* **프로세스를 돌릴 수 있는 사람에게서는 못 지킨다** — 그 사람은 키를 갖고 있다. 그리고 값이
|
|
29
|
+
* 화면으로 나가는 것도 이 칸이 막지 않는다: 내보내는 쪽이 어댑터 스키마의 `secret: true` 를 보고
|
|
30
|
+
* 가린다(§`reference-resolver` 의 상세 조회). **두 가지가 갈려 있으므로 한쪽만 하면 반쪽이다.**
|
|
31
|
+
*
|
|
32
|
+
* 기존 평문 행은 안 깨진다 — 변환기가 암호문 모양이 아닌 값을 알아보고 파싱하며 경고를 남기고,
|
|
33
|
+
* 다시 저장될 때 암호화된다.
|
|
34
|
+
*/
|
|
11
35
|
connectionConfig?: any;
|
|
12
36
|
mappingSpec?: any;
|
|
13
37
|
scopeSpec?: any;
|
|
@@ -9,7 +9,8 @@ const shell_1 = require("@things-factory/shell");
|
|
|
9
9
|
* TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018).
|
|
10
10
|
* 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티.
|
|
11
11
|
* inbound 전용(구조·상태). 아웃바운드 액추에이션은 command-routing(ActuationAdapter) 소유.
|
|
12
|
-
*
|
|
12
|
+
* mappingSpec/scopeSpec 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통).
|
|
13
|
+
* connectionConfig 는 자격 증명을 담으므로 암호화되는 텍스트 칸이다(칸 주석 참조).
|
|
13
14
|
* 설계 SoT: operato-twin/design/plans/reference-management.md.
|
|
14
15
|
* (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
|
|
15
16
|
*/
|
|
@@ -56,8 +57,8 @@ tslib_1.__decorate([
|
|
|
56
57
|
tslib_1.__metadata("design:type", String)
|
|
57
58
|
], TwinReference.prototype, "adapterType", void 0);
|
|
58
59
|
tslib_1.__decorate([
|
|
59
|
-
(0, typeorm_1.Column)({
|
|
60
|
-
(0, type_graphql_1.Field)(type => shell_1.ScalarObject, { nullable: true, description: 'Adapter-specific connection config (endpoint, credentials, params).' }),
|
|
60
|
+
(0, typeorm_1.Column)({ nullable: true, ...(0, shell_1.encryptedJsonColumn)() }),
|
|
61
|
+
(0, type_graphql_1.Field)(type => shell_1.ScalarObject, { nullable: true, description: 'Adapter-specific connection config (endpoint, credentials, params). Encrypted at rest; secret-declared fields are masked on the way out.' }),
|
|
61
62
|
tslib_1.__metadata("design:type", Object)
|
|
62
63
|
], TwinReference.prototype, "connectionConfig", void 0);
|
|
63
64
|
tslib_1.__decorate([
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"twin-reference.js","sourceRoot":"","sources":["../../../server/service/reference/twin-reference.ts"],"names":[],"mappings":";;;;AAAA,qCAAkI;AAClI,+CAAyD;AAEzD,
|
|
1
|
+
{"version":3,"file":"twin-reference.js","sourceRoot":"","sources":["../../../server/service/reference/twin-reference.ts"],"names":[],"mappings":"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","sourcesContent":["import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'\nimport { ObjectType, Field, ID, Int } from 'type-graphql'\n\nimport { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'\n\n/*\n * TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018).\n * 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티.\n * inbound 전용(구조·상태). 아웃바운드 액추에이션은 command-routing(ActuationAdapter) 소유.\n * mappingSpec/scopeSpec 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통).\n * connectionConfig 는 자격 증명을 담으므로 암호화되는 텍스트 칸이다(칸 주석 참조).\n * 설계 SoT: operato-twin/design/plans/reference-management.md.\n * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)\n */\n@Entity()\n@Index('ix_twin_reference_0', (e: TwinReference) => [e.domain, e.source], { unique: true })\n@ObjectType({ description: 'A tenant-scoped connection to an external source system — the origin for ingesting twin instances (Face2).' })\nexport class TwinReference {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier of the reference record.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })\n domain?: Domain\n\n @RelationId((e: TwinReference) => e.domain)\n domainId?: string\n\n @Column()\n @Field({ description: 'Reference source id (unique within a domain).' })\n source: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Domain kernel system: wms | yms | mes.' })\n system?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Human-readable site/system name.' })\n siteName?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Optional description.' })\n description?: string\n\n @Column()\n @Field({ description: \"Adapter type registry key (e.g. 'virtual' | 'rest' | 'db' | 'epcis').\" })\n adapterType: string\n\n /**\n * 이 연결에 필요한 값 — **자격 증명이 여기 들어온다. 그래서 저장될 때 암호화된다.**\n *\n * ── 왜 표에 두나 (2026-09-09) ─────────────────────────────────────────────\n * 비밀값은 환경에 두고 이름으로 고르는 것이 원칙이다(§`webhookSecretCandidates`). 그 원칙은\n * **설치본마다 하나인 키**에 맞는다 — 배포할 때 넣으면 된다.\n *\n * 트윈의 연결은 그 모양이 아니다. **사람이 화면에서 연결을 만든다.** 새 공장을 붙이려고 재배포할\n * 수는 없으므로, 연결마다 다른 이 값은 표에 있어야 한다. 그 자리에서 원칙이 갈린다:\n *\n * 설치본마다 하나인 키 환경변수. 이름이 어느 상대 것인지 말한다\n * 연결마다 다른 값 표. 대신 저장될 때 암호화한다\n *\n * ── 무엇을 지키고 무엇을 못 지키나 ────────────────────────────────────────\n * 저장된 것을 지킨다 — DB 파일 · 백업 · 복제본. 2026-09-09 에 이 칸에서 `hookSecret` 과 접속\n * 토큰을 명령 두 개로 읽었다.\n *\n * **프로세스를 돌릴 수 있는 사람에게서는 못 지킨다** — 그 사람은 키를 갖고 있다. 그리고 값이\n * 화면으로 나가는 것도 이 칸이 막지 않는다: 내보내는 쪽이 어댑터 스키마의 `secret: true` 를 보고\n * 가린다(§`reference-resolver` 의 상세 조회). **두 가지가 갈려 있으므로 한쪽만 하면 반쪽이다.**\n *\n * 기존 평문 행은 안 깨진다 — 변환기가 암호문 모양이 아닌 값을 알아보고 파싱하며 경고를 남기고,\n * 다시 저장될 때 암호화된다.\n */\n @Column({ nullable: true, ...encryptedJsonColumn() })\n @Field(type => ScalarObject, { nullable: true, description: 'Adapter-specific connection config (endpoint, credentials, params). Encrypted at rest; secret-declared fields are masked on the way out.' })\n connectionConfig?: any\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Declarative legacy→canonical mapping rules (kernel face2-adapter AdapterRule[]).' })\n mappingSpec?: any\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: '1:N fan-out scope (discovered sites). Multiple sites → N instances.' })\n scopeSpec?: any\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Lifecycle status: draft | connected | error.' })\n status?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last successful master sync time (ISO 8601 string; portable across DB drivers).' })\n lastSyncedAt?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Last connection/sync error message, if any.' })\n lastError?: string\n\n /**\n * 마지막 동기에서 **못 옮긴 값의 수**. `null` = 세지 않았다(0 과 다르다).\n *\n * 세지 않은 구조 인제스트는 도크를 랙으로 바꿔 놓고도 성공이라 답하면서 아무 일도 하지 않는다. 그 사실을 사후에 물을 수\n * 있어야 단계 판정이 「구조는 됐다」를 말할 수 있다 — 없는 동안 판정은 늘 `unmeasured` 였다.\n * 기본값을 두지 않는다: 0 을 기본으로 깔면 「경고 없었다」와 「세지 않았다」가 같아진다.\n */\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Number of values the last sync could not carry over (mapping fallbacks). Null means they were not counted at all, which is different from zero.' })\n lastWarningCount?: number\n\n /**\n * **어디까지 읽었나** — 라이브 피드의 읽기 커서(§`LiveFeedContinuity`).\n *\n * ── 왜 저장하나 (2026-08-23 실측) ──────────────────────────────────────────\n * 어댑터가 붙을 때마다 `Date.now() − 되돌아볼 날수` 로 창을 새로 만들고 있었다. 그래서 **재기동마다\n * 미러의 과거가 잘렸다** — 작업 2,855 → 2,820(8시간 흐른 만큼). 미러가 아는 것이 「원본의 사실」이\n * 아니라 「창의 함수」였고, 그 사실을 아무도 말하지 않았다.\n *\n * 커널이 그 갈림을 이미 적어 두었다(§`hydrateContinuity`): 「원천이 애초에 다시 말해 주지 않는 축」은\n * 재기동 연속성으로 이어받는다. 「우리가 어디까지 읽었나」가 정확히 그 성질이므로 **어댑터의 사물함이\n * 아니라 이 층**에 있다 — 원본이 늘 때마다 저장 기제가 늘고 그중 하나가 알리지 않고 다르게 동작하지 않게.\n *\n * ── 왜 캐시가 아니라 표인가 ────────────────────────────────────────────────\n * 만료되면 창이 다시 미끄러지고, 그 손실은 오류 없이 들어온 것이 없다. 스냅샷 체크포인트와 성질이 다르다\n * (그쪽은 잃어도 원천이 정정해 준다 — 이 값은 **잃으면 원천에 묻지 않게 된다**).\n *\n * 모양은 `{ streams: { [흐름]: { since?, seen[] } }, firstAttachedAt? }` 다. **흐름 이름은 어댑터가\n * 정한다** — 원본마다 흐름 수와 뜻이 다르므로 이 층은 열쇠로만 다룬다. `simple-json` 이라 드라이버\n * 다섯을 그대로 지난다.\n *\n * `firstAttachedAt` 은 「언제부터 아는가」다. 커널은 상한만 안다(`nowTime` = 마지막으로 들은 시각).\n * 둘이 함께 「이 트윈이 아는 구간」이고, 화면이 수를 보일 때 그 구간을 말해야 한다.\n */\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Live feed read cursor carried across restarts, keyed by adapter-defined stream. Null means the feed has never attached, and the next attach decides its first window.' })\n liveCursor?: any\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Timestamp when the reference was first created.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Timestamp when the reference row was last updated.' })\n updatedAt?: Date\n}\n"]}
|
|
@@ -7,6 +7,8 @@ export interface TwinEventKeys {
|
|
|
7
7
|
bizTransaction?: string;
|
|
8
8
|
locationId?: string;
|
|
9
9
|
moverId?: string;
|
|
10
|
+
/** `'actual'`, `'simulated'`, or absent — absent means not declared, which is not `actual`. */
|
|
11
|
+
provenance?: string;
|
|
10
12
|
}
|
|
11
13
|
/** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
|
|
12
14
|
export declare function bizStepOf(envelope: any): string | undefined;
|
|
@@ -88,5 +90,23 @@ export declare function equipmentIdOf(envelope: any): string | undefined;
|
|
|
88
90
|
* 동안에는 5개 드라이버 어디에서도 그 질문을 할 수 없었다. 운영 델타에는 행위가 없다 — 비운다.
|
|
89
91
|
*/
|
|
90
92
|
export declare function actionOf(envelope: any): string | undefined;
|
|
91
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* **Whether the sender declared this fact as having actually happened** — the envelope's
|
|
95
|
+
* `provenance`, promoted to a column.
|
|
96
|
+
*
|
|
97
|
+
* ── Why it is an indexed column (2026-09-09) ──────────────────────────────
|
|
98
|
+
* The journal held 1,134 rows, all of them generator output, and there was nowhere to ask about
|
|
99
|
+
* it. While the declaration sits inside `payload`, none of the five drivers can answer "count
|
|
100
|
+
* these without the invented ones" — the same reason `action` was promoted.
|
|
101
|
+
*
|
|
102
|
+
* ── Only the two declared values are taken ────────────────────────────────
|
|
103
|
+
* Anything that is not `actual` or `simulated` becomes `undefined`. A source's stray value does
|
|
104
|
+
* not reach the index (the discipline in `actionOf`).
|
|
105
|
+
*
|
|
106
|
+
* **`undefined` means "not declared", not "it happened".** A reader has to distinguish three
|
|
107
|
+
* states — declared simulated, declared actual, not declared. Folding the last two together
|
|
108
|
+
* means a generator that omits the field has its output counted as production.
|
|
109
|
+
*/
|
|
110
|
+
export declare function provenanceOf(envelope: any): string | undefined;
|
|
111
|
+
/** Every promoted key for one event — the write path calls this function and nothing else. */
|
|
92
112
|
export declare function twinEventKeys(envelope: any): TwinEventKeys;
|
|
@@ -7,6 +7,7 @@ exports.bizTransactionOf = bizTransactionOf;
|
|
|
7
7
|
exports.locationOf = locationOf;
|
|
8
8
|
exports.equipmentIdOf = equipmentIdOf;
|
|
9
9
|
exports.actionOf = actionOf;
|
|
10
|
+
exports.provenanceOf = provenanceOf;
|
|
10
11
|
exports.twinEventKeys = twinEventKeys;
|
|
11
12
|
const log_js_1 = require("../../engine/log.js");
|
|
12
13
|
/*
|
|
@@ -141,7 +142,32 @@ function actionOf(envelope) {
|
|
|
141
142
|
const a = (envelope?.data ?? envelope ?? {}).action;
|
|
142
143
|
return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined;
|
|
143
144
|
}
|
|
144
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* **Whether the sender declared this fact as having actually happened** — the envelope's
|
|
147
|
+
* `provenance`, promoted to a column.
|
|
148
|
+
*
|
|
149
|
+
* ── Why it is an indexed column (2026-09-09) ──────────────────────────────
|
|
150
|
+
* The journal held 1,134 rows, all of them generator output, and there was nowhere to ask about
|
|
151
|
+
* it. While the declaration sits inside `payload`, none of the five drivers can answer "count
|
|
152
|
+
* these without the invented ones" — the same reason `action` was promoted.
|
|
153
|
+
*
|
|
154
|
+
* ── Only the two declared values are taken ────────────────────────────────
|
|
155
|
+
* Anything that is not `actual` or `simulated` becomes `undefined`. A source's stray value does
|
|
156
|
+
* not reach the index (the discipline in `actionOf`).
|
|
157
|
+
*
|
|
158
|
+
* **`undefined` means "not declared", not "it happened".** A reader has to distinguish three
|
|
159
|
+
* states — declared simulated, declared actual, not declared. Folding the last two together
|
|
160
|
+
* means a generator that omits the field has its output counted as production.
|
|
161
|
+
*/
|
|
162
|
+
function provenanceOf(envelope) {
|
|
163
|
+
/*
|
|
164
|
+
* Read from the envelope only. `data` is where the source system writes, and a source that
|
|
165
|
+
* could declare its own facts actual would make this no longer the connecting side's statement.
|
|
166
|
+
*/
|
|
167
|
+
const p = envelope?.provenance;
|
|
168
|
+
return p === 'actual' || p === 'simulated' ? p : undefined;
|
|
169
|
+
}
|
|
170
|
+
/** Every promoted key for one event — the write path calls this function and nothing else. */
|
|
145
171
|
function twinEventKeys(envelope) {
|
|
146
172
|
return {
|
|
147
173
|
action: actionOf(envelope),
|
|
@@ -150,7 +176,8 @@ function twinEventKeys(envelope) {
|
|
|
150
176
|
orderId: clip(orderOf(envelope), 'orderId'),
|
|
151
177
|
bizTransaction: clip(bizTransactionOf(envelope), 'bizTransaction'),
|
|
152
178
|
locationId: clip(locationOf(envelope), 'locationId'),
|
|
153
|
-
moverId: clip(equipmentIdOf(envelope), 'moverId')
|
|
179
|
+
moverId: clip(equipmentIdOf(envelope), 'moverId'),
|
|
180
|
+
provenance: provenanceOf(envelope)
|
|
154
181
|
};
|
|
155
182
|
}
|
|
156
183
|
//# sourceMappingURL=twin-event-keys.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"twin-event-keys.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event-keys.ts"],"names":[],"mappings":"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","sourcesContent":["import { twinWarn } from '../../engine/log.js'\n/*\n * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.\n *\n * ── 왜 승격하는가 ───────────────────────────────────────────────────────────\n * 사용자가 저널에서 실제로 찾는 것은 \"이 팔레트의 이력\", \"이 오더가 어디까지 갔나\", \"이 도크에서\n * 무슨 일이 있었나\" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.\n * things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —\n * 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온\n * 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.\n *\n * 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —\n * 이건 파생 색인이지 새로운 진실이 아니다).\n *\n * ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────\n * 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면\n * 반드시 어긋나고, 어긋난 색인은 \"없는 것처럼 보이는 이벤트\" 를 만든다 — 저널에서 가장 나쁜 결함이다.\n */\n\n/** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */\nexport interface TwinEventKeys {\n action?: string\n bizStep?: string\n epc?: string\n orderId?: string\n bizTransaction?: string\n locationId?: string\n moverId?: string\n}\n\n/*\n * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.\n * 넘치는 값이 오면 **알리지 않고 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,\n * 그 사실이 어디에도 안 남으면 아무도 모른다.\n */\nconst MAX_KEY = 255\n\nfunction clip(v: unknown, field: string): string | undefined {\n if (v === undefined || v === null) return undefined\n const s = String(v)\n if (!s) return undefined\n if (s.length <= MAX_KEY) return s\n twinWarn(\n `[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +\n `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`\n )\n return s.slice(0, MAX_KEY)\n}\n\n/** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */\nexport function bizStepOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n const tail = String(d.bizStep ?? '').split(':').pop()\n return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined\n}\n\n/**\n * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).\n *\n * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라\n * 전체를 저장해 두면 끝마디(\"402.2\")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면\n * URN 으로 찾는 경로가 사라진다.\n */\nexport function epcOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined\n}\n\n/**\n * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.\n *\n * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────\n * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다\n * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도\n * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.\n *\n * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)\n * task.status 191,175 행 — 비어 있음 191,175 (100%)\n *\n * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수\n * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —\n * 시점을 어디로 옮겨도 0 이었다.\n *\n * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는\n * 사건부터 조회된다.\n *\n * ── 한 칸에 두 낱말이 들어 있었다 (2026-09-05 실측) ────────────────────────\n * 위 주석이 이미 알고 있었다 — 「EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와 같은\n * 것이 아닐 수 있다」. 그런데 그 둘을 **같은 칸에** 넣고 있었다. 그래서 이렇게 됐다.\n *\n * order.status order-1 내부 오더 id\n * epcis.TransformationEvent urn:epc:id:gdti:9521321.403.1 GS1 거래 식별자\n * epcis.TransactionEvent urn:epc:id:gdti:9521321.403.1\n *\n * 한 오더의 이력이 **색인 안에서 두 쪽으로 갈라진다.** 어느 이름으로 물어도 절반만 나오고,\n * 오류는 나지 않는다. 실측: 어휘가 섞인 행 10,004개.\n *\n * 이것이 실제로 막은 것 — 「이 개체를 어느 레시피로 만들었나」다. 답은 기록되어 있다(오더 사건이\n * `recipeKey` 를 든다, 실측 328,021/328,021 = 100%). 변환 사건은 그 오더를 GDTI 로 가리키는데\n * 오더 사건은 `order-1` 로 적혀 있어 **색인으로 이을 수가 없었다.**\n *\n * 그래서 칸을 갈랐다(사용자 결정 2026-09-05).\n *\n * orderId 내부 오더 id — 읽기 모델의 이음쇠\n * bizTransaction GS1 거래 식별자 — 표준이 말하는 그 거래\n *\n * 오더 사건은 **둘 다** 든다(`order.status` 의 payload 에 `orderId` 와 `bizTransaction` 이 함께\n * 있다). 그래서 EPCIS 사건의 거래 식별자로 오더를 찾을 수 있다 — 이 두 칸이 그 다리다.\n */\nexport function orderOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.orderId ?? undefined\n}\n\n/**\n * **그 사건이 가리키는 거래** — EPCIS `bizTransactionList` 의 첫 항목, 또는 운영 델타가 직접 든 값.\n *\n * 오더가 아니다. 표준이 말하는 거래(PO/SO/생산오더)이고, 한 오더가 여러 거래에 걸릴 수도 있다.\n * 그 구별을 지키려고 `orderId` 와 갈라 두었다 — 합치면 위 주석의 그 일이 다시 난다.\n */\nexport function bizTransactionOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.bizTransactionList?.[0]?.bizTransaction ?? d.bizTransaction ?? undefined\n}\n\n/**\n * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).\n * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서\n * 무엇을 했는지가 위치 축에서 통째로 사라진다.\n */\nexport function locationOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined\n}\n\n/**\n * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.\n *\n * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 \"이 지게차가 오늘 무엇을 했나\" 를\n * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 전수 조회하는 수밖에 없다.\n */\nexport function equipmentIdOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n /*\n * ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ────────────────────────────\n * 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을\n * 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부\n * 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.**\n *\n * 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴\n * 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를\n * 설비 축으로 물을 수 없었다.\n *\n * 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다.\n */\n return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined\n}\n\n/**\n * EPCIS 행위 — `ADD`·`OBSERVE`·`DELETE`. 세 값이 아니면 `undefined`(원천의 잡값을 색인에 넣지 않는다).\n *\n * 이 키는 검색 축이 아니라 **판별식**이다: 되풀어 읽는 스냅샷은 원리적으로 `DELETE` 를 못 낸다.\n * 그래서 「이 트윈이 반출을 한 번이라도 계산했는가」가 진짜 delta 피드의 증거인데, payload 안에 있는\n * 동안에는 5개 드라이버 어디에서도 그 질문을 할 수 없었다. 운영 델타에는 행위가 없다 — 비운다.\n */\nexport function actionOf(envelope: any): string | undefined {\n const a = (envelope?.data ?? envelope ?? {}).action\n return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined\n}\n\n/** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */\nexport function twinEventKeys(envelope: any): TwinEventKeys {\n return {\n action: actionOf(envelope),\n bizStep: clip(bizStepOf(envelope), 'bizStep'),\n epc: clip(epcOf(envelope), 'epc'),\n orderId: clip(orderOf(envelope), 'orderId'),\n bizTransaction: clip(bizTransactionOf(envelope), 'bizTransaction'),\n locationId: clip(locationOf(envelope), 'locationId'),\n moverId: clip(equipmentIdOf(envelope), 'moverId')\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"twin-event-keys.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event-keys.ts"],"names":[],"mappings":";;AAoDA,8BAIC;AASD,sBAGC;AA2CD,0BAGC;AAQD,4CAGC;AAOD,gCAGC;AAQD,sCAeC;AASD,4BAGC;AAmBD,oCAOC;AAGD,sCAWC;AAlND,gDAA8C;AAgC9C;;;;GAIG;AACH,MAAM,OAAO,GAAG,GAAG,CAAA;AAEnB,SAAS,IAAI,CAAC,CAAU,EAAE,KAAa;IACrC,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,SAAS,CAAA;IACnD,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IACnB,IAAI,CAAC,CAAC;QAAE,OAAO,SAAS,CAAA;IACxB,IAAI,CAAC,CAAC,MAAM,IAAI,OAAO;QAAE,OAAO,CAAC,CAAA;IACjC,IAAA,iBAAQ,EACN,qBAAqB,KAAK,YAAY,OAAO,wCAAwC;QACnF,0EAA0E,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAC/F,CAAA;IACD,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,CAAA;AAC5B,CAAC;AAED,8DAA8D;AAC9D,SAAgB,SAAS,CAAC,QAAa;IACrC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAA;IACrD,OAAO,IAAI,IAAI,MAAM,CAAC,QAAQ,EAAE,SAAS,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,SAAS,CAAA;AACrF,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,KAAK,CAAC,QAAa;IACjC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,IAAI,SAAS,CAAA;AACnF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,SAAgB,OAAO,CAAC,QAAa;IACnC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,OAAO,IAAI,SAAS,CAAA;AAC/B,CAAC;AAED;;;;;GAKG;AACH,SAAgB,gBAAgB,CAAC,QAAa;IAC5C,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAC,EAAE,cAAc,IAAI,CAAC,CAAC,cAAc,IAAI,SAAS,CAAA;AACnF,CAAC;AAED;;;;GAIG;AACH,SAAgB,UAAU,CAAC,QAAa;IACtC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C,OAAO,CAAC,CAAC,SAAS,EAAE,EAAE,IAAI,CAAC,CAAC,WAAW,EAAE,EAAE,IAAI,CAAC,CAAC,QAAQ,IAAI,SAAS,CAAA;AACxE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,QAAa;IACzC,MAAM,CAAC,GAAG,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAA;IAC1C;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,WAAW,IAAI,SAAS,CAAA;AACjE,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,QAAQ,CAAC,QAAa;IACpC,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,IAAI,QAAQ,IAAI,EAAE,CAAC,CAAC,MAAM,CAAA;IACnD,OAAO,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AACzE,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,SAAgB,YAAY,CAAC,QAAa;IACxC;;;OAGG;IACH,MAAM,CAAC,GAAG,QAAQ,EAAE,UAAU,CAAA;IAC9B,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AAC5D,CAAC;AAED,8FAA8F;AAC9F,SAAgB,aAAa,CAAC,QAAa;IACzC,OAAO;QACL,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC;QAC1B,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;QAC7C,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC;QACjC,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;QAC3C,cAAc,EAAE,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;QAClE,UAAU,EAAE,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;QACpD,OAAO,EAAE,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;QACjD,UAAU,EAAE,YAAY,CAAC,QAAQ,CAAC;KACnC,CAAA;AACH,CAAC","sourcesContent":["import { twinWarn } from '../../engine/log.js'\n/*\n * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.\n *\n * ── 왜 승격하는가 ───────────────────────────────────────────────────────────\n * 사용자가 저널에서 실제로 찾는 것은 \"이 팔레트의 이력\", \"이 오더가 어디까지 갔나\", \"이 도크에서\n * 무슨 일이 있었나\" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.\n * things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —\n * 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온\n * 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.\n *\n * 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —\n * 이건 파생 색인이지 새로운 진실이 아니다).\n *\n * ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────\n * 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면\n * 반드시 어긋나고, 어긋난 색인은 \"없는 것처럼 보이는 이벤트\" 를 만든다 — 저널에서 가장 나쁜 결함이다.\n */\n\n/** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */\nexport interface TwinEventKeys {\n action?: string\n bizStep?: string\n epc?: string\n orderId?: string\n bizTransaction?: string\n locationId?: string\n moverId?: string\n /** `'actual'`, `'simulated'`, or absent — absent means not declared, which is not `actual`. */\n provenance?: string\n}\n\n/*\n * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.\n * 넘치는 값이 오면 **알리지 않고 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,\n * 그 사실이 어디에도 안 남으면 아무도 모른다.\n */\nconst MAX_KEY = 255\n\nfunction clip(v: unknown, field: string): string | undefined {\n if (v === undefined || v === null) return undefined\n const s = String(v)\n if (!s) return undefined\n if (s.length <= MAX_KEY) return s\n twinWarn(\n `[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +\n `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`\n )\n return s.slice(0, MAX_KEY)\n}\n\n/** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */\nexport function bizStepOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n const tail = String(d.bizStep ?? '').split(':').pop()\n return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined\n}\n\n/**\n * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).\n *\n * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라\n * 전체를 저장해 두면 끝마디(\"402.2\")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면\n * URN 으로 찾는 경로가 사라진다.\n */\nexport function epcOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined\n}\n\n/**\n * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.\n *\n * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────\n * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다\n * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도\n * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.\n *\n * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)\n * task.status 191,175 행 — 비어 있음 191,175 (100%)\n *\n * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수\n * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —\n * 시점을 어디로 옮겨도 0 이었다.\n *\n * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는\n * 사건부터 조회된다.\n *\n * ── 한 칸에 두 낱말이 들어 있었다 (2026-09-05 실측) ────────────────────────\n * 위 주석이 이미 알고 있었다 — 「EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와 같은\n * 것이 아닐 수 있다」. 그런데 그 둘을 **같은 칸에** 넣고 있었다. 그래서 이렇게 됐다.\n *\n * order.status order-1 내부 오더 id\n * epcis.TransformationEvent urn:epc:id:gdti:9521321.403.1 GS1 거래 식별자\n * epcis.TransactionEvent urn:epc:id:gdti:9521321.403.1\n *\n * 한 오더의 이력이 **색인 안에서 두 쪽으로 갈라진다.** 어느 이름으로 물어도 절반만 나오고,\n * 오류는 나지 않는다. 실측: 어휘가 섞인 행 10,004개.\n *\n * 이것이 실제로 막은 것 — 「이 개체를 어느 레시피로 만들었나」다. 답은 기록되어 있다(오더 사건이\n * `recipeKey` 를 든다, 실측 328,021/328,021 = 100%). 변환 사건은 그 오더를 GDTI 로 가리키는데\n * 오더 사건은 `order-1` 로 적혀 있어 **색인으로 이을 수가 없었다.**\n *\n * 그래서 칸을 갈랐다(사용자 결정 2026-09-05).\n *\n * orderId 내부 오더 id — 읽기 모델의 이음쇠\n * bizTransaction GS1 거래 식별자 — 표준이 말하는 그 거래\n *\n * 오더 사건은 **둘 다** 든다(`order.status` 의 payload 에 `orderId` 와 `bizTransaction` 이 함께\n * 있다). 그래서 EPCIS 사건의 거래 식별자로 오더를 찾을 수 있다 — 이 두 칸이 그 다리다.\n */\nexport function orderOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.orderId ?? undefined\n}\n\n/**\n * **그 사건이 가리키는 거래** — EPCIS `bizTransactionList` 의 첫 항목, 또는 운영 델타가 직접 든 값.\n *\n * 오더가 아니다. 표준이 말하는 거래(PO/SO/생산오더)이고, 한 오더가 여러 거래에 걸릴 수도 있다.\n * 그 구별을 지키려고 `orderId` 와 갈라 두었다 — 합치면 위 주석의 그 일이 다시 난다.\n */\nexport function bizTransactionOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.bizTransactionList?.[0]?.bizTransaction ?? d.bizTransaction ?? undefined\n}\n\n/**\n * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).\n * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서\n * 무엇을 했는지가 위치 축에서 통째로 사라진다.\n */\nexport function locationOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined\n}\n\n/**\n * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.\n *\n * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 \"이 지게차가 오늘 무엇을 했나\" 를\n * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 전수 조회하는 수밖에 없다.\n */\nexport function equipmentIdOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n /*\n * ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ────────────────────────────\n * 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을\n * 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부\n * 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.**\n *\n * 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴\n * 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를\n * 설비 축으로 물을 수 없었다.\n *\n * 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다.\n */\n return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined\n}\n\n/**\n * EPCIS 행위 — `ADD`·`OBSERVE`·`DELETE`. 세 값이 아니면 `undefined`(원천의 잡값을 색인에 넣지 않는다).\n *\n * 이 키는 검색 축이 아니라 **판별식**이다: 되풀어 읽는 스냅샷은 원리적으로 `DELETE` 를 못 낸다.\n * 그래서 「이 트윈이 반출을 한 번이라도 계산했는가」가 진짜 delta 피드의 증거인데, payload 안에 있는\n * 동안에는 5개 드라이버 어디에서도 그 질문을 할 수 없었다. 운영 델타에는 행위가 없다 — 비운다.\n */\nexport function actionOf(envelope: any): string | undefined {\n const a = (envelope?.data ?? envelope ?? {}).action\n return a === 'ADD' || a === 'OBSERVE' || a === 'DELETE' ? a : undefined\n}\n\n/**\n * **Whether the sender declared this fact as having actually happened** — the envelope's\n * `provenance`, promoted to a column.\n *\n * ── Why it is an indexed column (2026-09-09) ──────────────────────────────\n * The journal held 1,134 rows, all of them generator output, and there was nowhere to ask about\n * it. While the declaration sits inside `payload`, none of the five drivers can answer \"count\n * these without the invented ones\" — the same reason `action` was promoted.\n *\n * ── Only the two declared values are taken ────────────────────────────────\n * Anything that is not `actual` or `simulated` becomes `undefined`. A source's stray value does\n * not reach the index (the discipline in `actionOf`).\n *\n * **`undefined` means \"not declared\", not \"it happened\".** A reader has to distinguish three\n * states — declared simulated, declared actual, not declared. Folding the last two together\n * means a generator that omits the field has its output counted as production.\n */\nexport function provenanceOf(envelope: any): string | undefined {\n /*\n * Read from the envelope only. `data` is where the source system writes, and a source that\n * could declare its own facts actual would make this no longer the connecting side's statement.\n */\n const p = envelope?.provenance\n return p === 'actual' || p === 'simulated' ? p : undefined\n}\n\n/** Every promoted key for one event — the write path calls this function and nothing else. */\nexport function twinEventKeys(envelope: any): TwinEventKeys {\n return {\n action: actionOf(envelope),\n bizStep: clip(bizStepOf(envelope), 'bizStep'),\n epc: clip(epcOf(envelope), 'epc'),\n orderId: clip(orderOf(envelope), 'orderId'),\n bizTransaction: clip(bizTransactionOf(envelope), 'bizTransaction'),\n locationId: clip(locationOf(envelope), 'locationId'),\n moverId: clip(equipmentIdOf(envelope), 'moverId'),\n provenance: provenanceOf(envelope)\n }\n}\n"]}
|
|
@@ -35,6 +35,41 @@ export declare class TwinEvent {
|
|
|
35
35
|
bizTransaction?: string;
|
|
36
36
|
locationId?: string;
|
|
37
37
|
moverId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* **Whether the sender declared this fact as having actually happened** — the envelope's
|
|
40
|
+
* `provenance`, promoted here.
|
|
41
|
+
*
|
|
42
|
+
* ── Why a column (measured 2026-09-09) ──────────────────────────────────
|
|
43
|
+
* This table held 1,134 rows, all of them generator output, and **there was nowhere to ask
|
|
44
|
+
* about it**:
|
|
45
|
+
*
|
|
46
|
+
* ```
|
|
47
|
+
* select count(*) from twin_events 1134
|
|
48
|
+
* select count(*) where payload like '%SIM-%' 1134
|
|
49
|
+
* select count(*) where order_id like 'WO-SIM-%' 558 <- half of them
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* The only signal was the sender's naming convention (`SIM-`), and the column that convention
|
|
53
|
+
* reaches was empty on half the rows. While it sits inside `payload`, none of the five drivers
|
|
54
|
+
* can answer "count these without the invented ones" — the same reason `action` was promoted.
|
|
55
|
+
*
|
|
56
|
+
* ── Three states, kept apart ────────────────────────────────────────────
|
|
57
|
+
* ```
|
|
58
|
+
* 'simulated' the sender said it was invented
|
|
59
|
+
* 'actual' the sender said it happened
|
|
60
|
+
* null **not declared** — which is not `actual`
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* Folding the last two together means a generator that omits the declaration has its output
|
|
64
|
+
* counted as production, and nothing says so. Hence no default: an absent declaration is not
|
|
65
|
+
* disguised as a valid-looking value (the discipline of `structureRev` and `lastWarningCount`).
|
|
66
|
+
*
|
|
67
|
+
* ── No index ────────────────────────────────────────────────────────────
|
|
68
|
+
* Three possible values is low cardinality within one twin — the judgement already made for
|
|
69
|
+
* `bizStep` and `locationId`. Performance folds arrive already narrowed by domain, instance and
|
|
70
|
+
* time, so filtering after that is cheap.
|
|
71
|
+
*/
|
|
72
|
+
provenance?: string;
|
|
38
73
|
payload?: any;
|
|
39
74
|
createdAt?: Date;
|
|
40
75
|
}
|
|
@@ -140,6 +140,11 @@ tslib_1.__decorate([
|
|
|
140
140
|
(0, type_graphql_1.Field)({ nullable: true, description: 'Equipment identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server. The column keeps the legacy name `moverId`: the journal is append-only history and mixing two names for the same fact across time is worse than an outdated name. Renaming belongs with event-schema versioning.' }),
|
|
141
141
|
tslib_1.__metadata("design:type", String)
|
|
142
142
|
], TwinEvent.prototype, "moverId", void 0);
|
|
143
|
+
tslib_1.__decorate([
|
|
144
|
+
(0, typeorm_1.Column)({ length: 16, nullable: true }),
|
|
145
|
+
(0, type_graphql_1.Field)({ nullable: true, description: 'Whether the sending side declared this fact as actually having happened. Either "actual" or "simulated"; null means it was not declared, which is not the same as actual — a generator that omits it must not be counted as production.' }),
|
|
146
|
+
tslib_1.__metadata("design:type", String)
|
|
147
|
+
], TwinEvent.prototype, "provenance", void 0);
|
|
143
148
|
tslib_1.__decorate([
|
|
144
149
|
(0, typeorm_1.Column)({ type: 'simple-json', nullable: true }),
|
|
145
150
|
(0, type_graphql_1.Field)(type => shell_1.ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"twin-event.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event.ts"],"names":[],"mappings":"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","sourcesContent":["import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'\nimport { ObjectType, Field, ID, Int } from 'type-graphql'\n\nimport { Domain, ScalarObject } from '@things-factory/shell'\n\n/*\n * TwinEvent — append-only 트윈 이벤트 저널(EPCIS 이벤트 + 운영 델타).\n * 커널 EventJournal 의 영속 대응 — 상태=이벤트열의 함수(재부팅 시 DB→replay 재구성).\n * append-only 이므로 updater/deletedAt 없음(이력 CRUD 가 아니라 불변 이벤트 스트림).\n * payload 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통, DB-specific JSON 타입 금지).\n * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)\n */\n/*\n * ── 인덱스 설계 (2026-07-31) ────────────────────────────────────────────────\n * 이 표는 **인제스트 경로의 뜨거운 append-only 테이블**이다. 인덱스 하나하나가 쓰기 증폭이므로\n * \"있으면 좋을\" 인덱스를 붙이지 않는다. 실제 질의 패턴에 대응하는 것만 둔다.\n *\n * ix_0 (domain, instanceId, revision) 원장 기본 정렬·커서 페이징·replay(ASC 주사)\n * ix_1 (domain, instanceId, eventTime) 시각 커서(untilTime)·시간창 KPI — 거의 모든 조회가 탄다\n * ix_2 (domain, instanceId, eventType, revision) 타입 필터 + 정렬 동시 충족(스케줄 화면 task/equipment)\n * ix_3 (domain, instanceId, epc) \"이 물건의 이력\" — Entity360 의 본질 질문\n * ix_4 (domain, instanceId, orderId) \"이 오더가 어디까지 갔나\"\n * ix_9 (domain, instanceId, bizTransaction) \"이 거래에 무슨 일이 있었나\" — EPCIS↔오더의 다리\n *\n * bizStep·locationId·moverId 는 컬럼만 두고 인덱스는 두지 않는다 — 한 트윈 안에서 카디널리티가\n * 낮아(업무단계 몇 개, 위치 수백, 설비 수십) (domain,instanceId) 로 이미 좁혀진 뒤의 잔여 필터로\n * 충분하고, 뜨거운 표에 인덱스를 더 얹을 가치가 없다. 저널 하나가 아주 커져서 이 축들의 조회가\n * 느려지면 그때 측정을 근거로 인덱스를 추가할 일이지, 지레 얹어 쓰기를 무겁게 할 일은 아니다.\n */\n@Entity()\n@Index('ix_twin_event_0', (e: TwinEvent) => [e.domain, e.instanceId, e.revision], { unique: false })\n@Index('ix_twin_event_1', (e: TwinEvent) => [e.domain, e.instanceId, e.eventTime], { unique: false })\n@Index('ix_twin_event_2', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.revision], { unique: false })\n@Index('ix_twin_event_3', (e: TwinEvent) => [e.domain, e.instanceId, e.epc], { unique: false })\n@Index('ix_twin_event_4', (e: TwinEvent) => [e.domain, e.instanceId, e.orderId], { unique: false })\n/* ix_5 — \"이 지시가 무엇을 일으켰나\". 감사 기록의 commandId 로 저널을 다시 계산하는 축. */\n@Index('ix_twin_event_5', (e: TwinEvent) => [e.domain, e.correlationId], { unique: false })\n/* ix_6 — \"반출을 한 번이라도 계산했나\". 없으면 그 질문이 인스턴스 저널 전량 주사가 된다. */\n@Index('ix_twin_event_6', (e: TwinEvent) => [e.domain, e.instanceId, e.action], { unique: false })\n/*\n * ix_7 — **\"이 종류의 사실을 시각순으로\"** (2026-08-25 실측으로 추가)\n *\n * 이 축이 없어서 공정 타임라인이 22초였다. 종류로 거르고 시각으로 정렬하는 조회가 갈 곳이 없었다:\n *\n * ix_2 (…, eventType, revision) 종류는 걸러지고 정렬이 **적힌 순서**다 → 4월 사실을 최근으로 그렸다\n * ix_1 (…, eventTime) 정렬은 맞고 **종류를 못 거른다** → 행마다 테이블을 열어 확인한다\n *\n * 뒤쪽으로 걸으면 그 트윈에서 찾는 종류가 멈춘 시점까지의 모든 행을 읽고 버린다. 승화푸드에서 작업\n * 사실은 8월 22일에 멈춰 있고 그 뒤로 리비전이 110만 늘었으므로, 200건에 닿기까지 110만 행을 열었다.\n *\n * 위 주석이 「측정을 근거로 추가할 일이지 지레 얹을 일은 아니다」라고 적어 두었다. 그 측정이 나왔다.\n */\n@Index('ix_twin_event_7', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.eventTime], { unique: false })\n/*\n * ix_8 — **\"이 트윈의 오래된 기록\"** (2026-08-27 실측으로 추가)\n *\n * 보관 기간 정리가 `created_at` 으로 자르는데(보관은 저장 나이의 문제다) 그 축에 인덱스가 없었다.\n * 그래서 정리가 매 배치마다 그 트윈의 저널을 전량 주사했다.\n *\n * hatio-mx2 의 7일 밖 행을 세기 208초 ← 1,029만 행을 전부 읽는다(7일 밖은 2,446건뿐이다)\n * revision 으로 한 행 짚기 0.004초\n *\n * 정리는 한 창에 2초만 쓰도록 예산이 걸려 있어서, 그 2초를 주사에 다 쓰고 44건만 지우고 멈췄다.\n */\n@Index('ix_twin_event_8', (e: TwinEvent) => [e.domain, e.instanceId, e.createdAt], { unique: false })\n/* ix_9 — \"이 거래에 무슨 일이 있었나\". EPCIS 사건에서 오더로 건너가는 다리(§`bizTransaction`). */\n@Index('ix_twin_event_9', (e: TwinEvent) => [e.domain, e.instanceId, e.bizTransaction], { unique: false })\n@ObjectType({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' })\nexport class TwinEvent {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier of the event record.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })\n domain?: Domain\n\n @RelationId((e: TwinEvent) => e.domain)\n domainId?: string\n\n /*\n * 이 사실을 낸 트윈. **빌 수 없다** — 저널 행을 만드는 곳은 `journalRow` 하나뿐이고 거기서\n * 필수 인자로 받는다. nullable 로 두면 읽는 쪽이 매번 `?? ''` 를 달게 되고, 그 방어가 실제로는\n * 일어나지 않는 경우를 대비하느라 \"빈 인스턴스의 이벤트\" 라는 없는 개념을 코드에 남긴다.\n */\n @Column()\n @Field({ description: 'Twin runtime instance id that emitted the event.' })\n instanceId: string\n\n /* 커널 봉투의 필수 필드(`CanonicalEnvelope.eventType`)라 빌 수 없다 — 계약을 그대로 옮긴다. */\n @Column()\n @Field({ description: 'Event type: epcis.* | task.status | equipment.status | order.status.' })\n eventType: string\n\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Monotonic state revision at which the event was emitted.' })\n revision?: number\n\n /**\n * 이 사실이 **어느 공장에서** 일어났나 — `TwinStructure.rev`.\n *\n * 이것이 없던 시절에는 구조가 바뀌면 저널을 통째로 지우는 것이 유일한 길이었다(안 지우면 옛\n * 이벤트를 새 공장에 대고 계산해 이력이 거짓말을 한다). 이 한 칸이 셋째 길을 연다 — 재생이\n * 구조가 바뀌는 지점에서 전환한 뒤 이어서 계산한다.\n *\n * **비어 있을 수 있다**: 컬럼이 생기기 전에 쓰인 행이다. 그 행들은 **가장 오래된 리비전**에\n * 속한다(0 을 채워 넣지 않는다 — 0 은 \"모름\" 이 아니라 유효한 번호처럼 보인다).\n */\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Structure revision (TwinStructure.rev) in force when this event happened. Absent on rows written before structure revisions existed; those belong to the oldest revision.' })\n structureRev?: number\n\n /*\n * 이 사실이 일어난 시각(트윈의 시계). **날짜 타입이다.**\n *\n * 예전에는 문자열이었다. 읽을 때마다 `Date.parse` 했고, 정렬은 UTC ISO 만 들어온다는 전제 위에서\n * 사전식 비교가 우연히 맞았다 — `+09:00` 오프셋 표기가 하나 섞이면 알리지 않고 순서가 틀린다.\n * 그 전제는 컬럼 어디에도 적혀 있지 않았다.\n *\n * **타입 이름을 적지 않는다** (2026-08-19 Postgres 부팅 실패로 고침).\n *\n * `datetime` 을 적었더니 Postgres 가 부팅 중에 거절했다(`DataTypeNotSupportedError`) — 그 이름은\n * sqlite·MySQL 의 것이고 Postgres 는 `timestamp` 만 안다. 거꾸로 `timestamp` 는 sqlite 가 모른다:\n * **다섯 드라이버에 다 통하는 이름이 아예 없다.**\n *\n * 그래서 이름을 고르는 일을 TypeORM 에 맡긴다. `emitDecoratorMetadata` 가 켜져 있으므로 이 속성의\n * TS 타입(`Date`)에서 드라이버별 타입을 스스로 고른다(Postgres `timestamp` · sqlite·MySQL `datetime`).\n * 이 레포의 다중 DB 규약이 요구하는 것이 바로 그것이다 — 드라이버 방언을 코드에 적지 않는다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Time at which the fact happened on the twin clock.' })\n eventTime?: Date\n\n /*\n * ── 승격된 검색 축 ────────────────────────────────────────────────────────\n * payload 안에 있던 값을 인제스트 시점에 꺼내 실컬럼으로 둔다(`twin-event-keys.ts` 가 단독 소유).\n * payload 가 여전히 정본이고 이것들은 **파생 색인**이다 — 새로운 진실이 아니라 찾을 수 있게 하는 장치.\n * simple-json(TEXT) 안의 값은 5개 DB 드라이버 공통으로 거를 방법이 없어서(멀티DB 호환 규칙상\n * DB별 JSON 연산자 금지) 승격 외의 선택지가 없다.\n * 길이 상한 255 는 GS1 식별자 규격 대비 충분하며, 넘치는 값은 알리지 않고 잘리지 않고 경고를 남긴다.\n *\n * ── 축을 새로 추가할 때 ──────────────────────────────────────────────────\n * 새 승격 컬럼을 만들면 **이미 쌓인 행은 비어 있다.** 그 행들은 새 축으로 검색되지 않고,\n * 사용자에게는 \"과거가 없는 것\" 으로 보인다(알리지 않고 빠진 데이터).\n *\n * 예전에는 기동 훅이 매번 테이블을 살펴 채웠다. 그건 두 가지로 틀렸다 — 저널이 커질수록 부팅이\n * 느려지고, \"bizStep 이 비었으면 옛 행\" 이라는 전제를 코드에 고정해 그 전제가 깨지는 순간\n * (감사 행처럼 업무 단계가 없는 행) 매 부팅마다 헛돌았다. 그래서 걷어냈다(git: backfill-keys.ts).\n *\n * 채우는 일은 **마이그레이션 한 번**이 맞다(`yarn migration`). 그 컬럼에 맞는 것을 그때 쓴다 —\n * 범용 스캐너를 되살리지 말 것. 개발 데이타라면 그냥 버리는 것도 답이다.\n */\n /**\n * 이 사실을 일으킨 **지시** — 커널이 커맨드에서 이어 준 상관값(기본은 commandId).\n *\n * 이것이 감사 기록(TwinAuditEvent.commandId)과 저널을 잇는 유일한 다리다. 없으면 승인 기록은\n * \"사람이 허락했다\" 까지이고, 그 뒤 공장이 실제로 어떻게 움직였는지와 이어지지 않는다.\n *\n * **대부분의 행은 비어 있다** — 커맨드가 아니라 시뮬 시간이 낳은 사실이기 때문이다. 그것이 정상이고,\n * 비어 있다고 \"덜 채워진 옛 행\" 으로 보면 안 된다(같은 오해를 bizStep 에서 이미 한 번 했다).\n */\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Id of the command that caused this fact (kernel correlation id; defaults to the commandId). Empty for facts that simulation time produced rather than a command.' })\n correlationId?: string\n\n /* 옛 행은 비어 있다 — 승격 전에 기록된 것이라 **모른다**(0 이나 OBSERVE 로 위장하지 않는다). */\n @Column({ length: 16, nullable: true })\n @Field({ nullable: true, description: 'EPCIS action (ADD / OBSERVE / DELETE), promoted from the payload. Empty for operational deltas, and for rows journaled before this column existed.' })\n action?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Business step (CBV bizStep tail), promoted from the payload for indexed filtering.' })\n bizStep?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Item identifier (EPC / EPC class / parent id), promoted from the payload for indexed lookup of one item history.' })\n epc?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Internal order identifier, promoted from the payload for indexed lookup of one order history.' })\n orderId?: string\n\n /*\n * **거래 식별자는 오더 식별자와 다른 것이다.**\n *\n * 전에는 둘이 한 칸(`orderId`)에 들어갔다. 그래서 한 오더의 이력이 색인 안에서 두 쪽으로\n * 갈라졌다 — 운영 델타는 `order-1`, EPCIS 사건은 `urn:epc:id:gdti:…` 로 적혀 어느 이름으로\n * 물어도 절반만 나왔다(실측 2026-09-05: 어휘가 섞인 행 10,004개, 오류는 나지 않음).\n *\n * 오더 사건은 두 값을 다 들고 있다. 그래서 이 칸이 **EPCIS 사건에서 오더로 건너가는 다리**가\n * 된다 — 「이 개체를 어느 레시피로 만들었나」가 그 다리로 답해진다.\n */\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Business transaction identifier (PO / SO / production order), promoted from the payload for indexed lookup of one transaction.' })\n bizTransaction?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Location identifier (read point, business location, or the plain location an operational delta carries), promoted from the payload for indexed filtering.' })\n locationId?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Equipment identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server. The column keeps the legacy name `moverId`: the journal is append-only history and mixing two names for the same fact across time is worse than an outdated name. Renaming belongs with event-schema versioning.' })\n moverId?: string\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' })\n payload?: any\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Wall-clock timestamp when the record was persisted.' })\n createdAt?: Date\n}\n"]}
|
|
1
|
+
{"version":3,"file":"twin-event.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event.ts"],"names":[],"mappings":"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","sourcesContent":["import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'\nimport { ObjectType, Field, ID, Int } from 'type-graphql'\n\nimport { Domain, ScalarObject } from '@things-factory/shell'\n\n/*\n * TwinEvent — append-only 트윈 이벤트 저널(EPCIS 이벤트 + 운영 델타).\n * 커널 EventJournal 의 영속 대응 — 상태=이벤트열의 함수(재부팅 시 DB→replay 재구성).\n * append-only 이므로 updater/deletedAt 없음(이력 CRUD 가 아니라 불변 이벤트 스트림).\n * payload 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통, DB-specific JSON 타입 금지).\n * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)\n */\n/*\n * ── 인덱스 설계 (2026-07-31) ────────────────────────────────────────────────\n * 이 표는 **인제스트 경로의 뜨거운 append-only 테이블**이다. 인덱스 하나하나가 쓰기 증폭이므로\n * \"있으면 좋을\" 인덱스를 붙이지 않는다. 실제 질의 패턴에 대응하는 것만 둔다.\n *\n * ix_0 (domain, instanceId, revision) 원장 기본 정렬·커서 페이징·replay(ASC 주사)\n * ix_1 (domain, instanceId, eventTime) 시각 커서(untilTime)·시간창 KPI — 거의 모든 조회가 탄다\n * ix_2 (domain, instanceId, eventType, revision) 타입 필터 + 정렬 동시 충족(스케줄 화면 task/equipment)\n * ix_3 (domain, instanceId, epc) \"이 물건의 이력\" — Entity360 의 본질 질문\n * ix_4 (domain, instanceId, orderId) \"이 오더가 어디까지 갔나\"\n * ix_9 (domain, instanceId, bizTransaction) \"이 거래에 무슨 일이 있었나\" — EPCIS↔오더의 다리\n *\n * bizStep·locationId·moverId 는 컬럼만 두고 인덱스는 두지 않는다 — 한 트윈 안에서 카디널리티가\n * 낮아(업무단계 몇 개, 위치 수백, 설비 수십) (domain,instanceId) 로 이미 좁혀진 뒤의 잔여 필터로\n * 충분하고, 뜨거운 표에 인덱스를 더 얹을 가치가 없다. 저널 하나가 아주 커져서 이 축들의 조회가\n * 느려지면 그때 측정을 근거로 인덱스를 추가할 일이지, 지레 얹어 쓰기를 무겁게 할 일은 아니다.\n */\n@Entity()\n@Index('ix_twin_event_0', (e: TwinEvent) => [e.domain, e.instanceId, e.revision], { unique: false })\n@Index('ix_twin_event_1', (e: TwinEvent) => [e.domain, e.instanceId, e.eventTime], { unique: false })\n@Index('ix_twin_event_2', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.revision], { unique: false })\n@Index('ix_twin_event_3', (e: TwinEvent) => [e.domain, e.instanceId, e.epc], { unique: false })\n@Index('ix_twin_event_4', (e: TwinEvent) => [e.domain, e.instanceId, e.orderId], { unique: false })\n/* ix_5 — \"이 지시가 무엇을 일으켰나\". 감사 기록의 commandId 로 저널을 다시 계산하는 축. */\n@Index('ix_twin_event_5', (e: TwinEvent) => [e.domain, e.correlationId], { unique: false })\n/* ix_6 — \"반출을 한 번이라도 계산했나\". 없으면 그 질문이 인스턴스 저널 전량 주사가 된다. */\n@Index('ix_twin_event_6', (e: TwinEvent) => [e.domain, e.instanceId, e.action], { unique: false })\n/*\n * ix_7 — **\"이 종류의 사실을 시각순으로\"** (2026-08-25 실측으로 추가)\n *\n * 이 축이 없어서 공정 타임라인이 22초였다. 종류로 거르고 시각으로 정렬하는 조회가 갈 곳이 없었다:\n *\n * ix_2 (…, eventType, revision) 종류는 걸러지고 정렬이 **적힌 순서**다 → 4월 사실을 최근으로 그렸다\n * ix_1 (…, eventTime) 정렬은 맞고 **종류를 못 거른다** → 행마다 테이블을 열어 확인한다\n *\n * 뒤쪽으로 걸으면 그 트윈에서 찾는 종류가 멈춘 시점까지의 모든 행을 읽고 버린다. 승화푸드에서 작업\n * 사실은 8월 22일에 멈춰 있고 그 뒤로 리비전이 110만 늘었으므로, 200건에 닿기까지 110만 행을 열었다.\n *\n * 위 주석이 「측정을 근거로 추가할 일이지 지레 얹을 일은 아니다」라고 적어 두었다. 그 측정이 나왔다.\n */\n@Index('ix_twin_event_7', (e: TwinEvent) => [e.domain, e.instanceId, e.eventType, e.eventTime], { unique: false })\n/*\n * ix_8 — **\"이 트윈의 오래된 기록\"** (2026-08-27 실측으로 추가)\n *\n * 보관 기간 정리가 `created_at` 으로 자르는데(보관은 저장 나이의 문제다) 그 축에 인덱스가 없었다.\n * 그래서 정리가 매 배치마다 그 트윈의 저널을 전량 주사했다.\n *\n * hatio-mx2 의 7일 밖 행을 세기 208초 ← 1,029만 행을 전부 읽는다(7일 밖은 2,446건뿐이다)\n * revision 으로 한 행 짚기 0.004초\n *\n * 정리는 한 창에 2초만 쓰도록 예산이 걸려 있어서, 그 2초를 주사에 다 쓰고 44건만 지우고 멈췄다.\n */\n@Index('ix_twin_event_8', (e: TwinEvent) => [e.domain, e.instanceId, e.createdAt], { unique: false })\n/* ix_9 — \"이 거래에 무슨 일이 있었나\". EPCIS 사건에서 오더로 건너가는 다리(§`bizTransaction`). */\n@Index('ix_twin_event_9', (e: TwinEvent) => [e.domain, e.instanceId, e.bizTransaction], { unique: false })\n@ObjectType({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' })\nexport class TwinEvent {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier of the event record.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' })\n domain?: Domain\n\n @RelationId((e: TwinEvent) => e.domain)\n domainId?: string\n\n /*\n * 이 사실을 낸 트윈. **빌 수 없다** — 저널 행을 만드는 곳은 `journalRow` 하나뿐이고 거기서\n * 필수 인자로 받는다. nullable 로 두면 읽는 쪽이 매번 `?? ''` 를 달게 되고, 그 방어가 실제로는\n * 일어나지 않는 경우를 대비하느라 \"빈 인스턴스의 이벤트\" 라는 없는 개념을 코드에 남긴다.\n */\n @Column()\n @Field({ description: 'Twin runtime instance id that emitted the event.' })\n instanceId: string\n\n /* 커널 봉투의 필수 필드(`CanonicalEnvelope.eventType`)라 빌 수 없다 — 계약을 그대로 옮긴다. */\n @Column()\n @Field({ description: 'Event type: epcis.* | task.status | equipment.status | order.status.' })\n eventType: string\n\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Monotonic state revision at which the event was emitted.' })\n revision?: number\n\n /**\n * 이 사실이 **어느 공장에서** 일어났나 — `TwinStructure.rev`.\n *\n * 이것이 없던 시절에는 구조가 바뀌면 저널을 통째로 지우는 것이 유일한 길이었다(안 지우면 옛\n * 이벤트를 새 공장에 대고 계산해 이력이 거짓말을 한다). 이 한 칸이 셋째 길을 연다 — 재생이\n * 구조가 바뀌는 지점에서 전환한 뒤 이어서 계산한다.\n *\n * **비어 있을 수 있다**: 컬럼이 생기기 전에 쓰인 행이다. 그 행들은 **가장 오래된 리비전**에\n * 속한다(0 을 채워 넣지 않는다 — 0 은 \"모름\" 이 아니라 유효한 번호처럼 보인다).\n */\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Structure revision (TwinStructure.rev) in force when this event happened. Absent on rows written before structure revisions existed; those belong to the oldest revision.' })\n structureRev?: number\n\n /*\n * 이 사실이 일어난 시각(트윈의 시계). **날짜 타입이다.**\n *\n * 예전에는 문자열이었다. 읽을 때마다 `Date.parse` 했고, 정렬은 UTC ISO 만 들어온다는 전제 위에서\n * 사전식 비교가 우연히 맞았다 — `+09:00` 오프셋 표기가 하나 섞이면 알리지 않고 순서가 틀린다.\n * 그 전제는 컬럼 어디에도 적혀 있지 않았다.\n *\n * **타입 이름을 적지 않는다** (2026-08-19 Postgres 부팅 실패로 고침).\n *\n * `datetime` 을 적었더니 Postgres 가 부팅 중에 거절했다(`DataTypeNotSupportedError`) — 그 이름은\n * sqlite·MySQL 의 것이고 Postgres 는 `timestamp` 만 안다. 거꾸로 `timestamp` 는 sqlite 가 모른다:\n * **다섯 드라이버에 다 통하는 이름이 아예 없다.**\n *\n * 그래서 이름을 고르는 일을 TypeORM 에 맡긴다. `emitDecoratorMetadata` 가 켜져 있으므로 이 속성의\n * TS 타입(`Date`)에서 드라이버별 타입을 스스로 고른다(Postgres `timestamp` · sqlite·MySQL `datetime`).\n * 이 레포의 다중 DB 규약이 요구하는 것이 바로 그것이다 — 드라이버 방언을 코드에 적지 않는다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Time at which the fact happened on the twin clock.' })\n eventTime?: Date\n\n /*\n * ── 승격된 검색 축 ────────────────────────────────────────────────────────\n * payload 안에 있던 값을 인제스트 시점에 꺼내 실컬럼으로 둔다(`twin-event-keys.ts` 가 단독 소유).\n * payload 가 여전히 정본이고 이것들은 **파생 색인**이다 — 새로운 진실이 아니라 찾을 수 있게 하는 장치.\n * simple-json(TEXT) 안의 값은 5개 DB 드라이버 공통으로 거를 방법이 없어서(멀티DB 호환 규칙상\n * DB별 JSON 연산자 금지) 승격 외의 선택지가 없다.\n * 길이 상한 255 는 GS1 식별자 규격 대비 충분하며, 넘치는 값은 알리지 않고 잘리지 않고 경고를 남긴다.\n *\n * ── 축을 새로 추가할 때 ──────────────────────────────────────────────────\n * 새 승격 컬럼을 만들면 **이미 쌓인 행은 비어 있다.** 그 행들은 새 축으로 검색되지 않고,\n * 사용자에게는 \"과거가 없는 것\" 으로 보인다(알리지 않고 빠진 데이터).\n *\n * 예전에는 기동 훅이 매번 테이블을 살펴 채웠다. 그건 두 가지로 틀렸다 — 저널이 커질수록 부팅이\n * 느려지고, \"bizStep 이 비었으면 옛 행\" 이라는 전제를 코드에 고정해 그 전제가 깨지는 순간\n * (감사 행처럼 업무 단계가 없는 행) 매 부팅마다 헛돌았다. 그래서 걷어냈다(git: backfill-keys.ts).\n *\n * 채우는 일은 **마이그레이션 한 번**이 맞다(`yarn migration`). 그 컬럼에 맞는 것을 그때 쓴다 —\n * 범용 스캐너를 되살리지 말 것. 개발 데이타라면 그냥 버리는 것도 답이다.\n */\n /**\n * 이 사실을 일으킨 **지시** — 커널이 커맨드에서 이어 준 상관값(기본은 commandId).\n *\n * 이것이 감사 기록(TwinAuditEvent.commandId)과 저널을 잇는 유일한 다리다. 없으면 승인 기록은\n * \"사람이 허락했다\" 까지이고, 그 뒤 공장이 실제로 어떻게 움직였는지와 이어지지 않는다.\n *\n * **대부분의 행은 비어 있다** — 커맨드가 아니라 시뮬 시간이 낳은 사실이기 때문이다. 그것이 정상이고,\n * 비어 있다고 \"덜 채워진 옛 행\" 으로 보면 안 된다(같은 오해를 bizStep 에서 이미 한 번 했다).\n */\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Id of the command that caused this fact (kernel correlation id; defaults to the commandId). Empty for facts that simulation time produced rather than a command.' })\n correlationId?: string\n\n /* 옛 행은 비어 있다 — 승격 전에 기록된 것이라 **모른다**(0 이나 OBSERVE 로 위장하지 않는다). */\n @Column({ length: 16, nullable: true })\n @Field({ nullable: true, description: 'EPCIS action (ADD / OBSERVE / DELETE), promoted from the payload. Empty for operational deltas, and for rows journaled before this column existed.' })\n action?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Business step (CBV bizStep tail), promoted from the payload for indexed filtering.' })\n bizStep?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Item identifier (EPC / EPC class / parent id), promoted from the payload for indexed lookup of one item history.' })\n epc?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Internal order identifier, promoted from the payload for indexed lookup of one order history.' })\n orderId?: string\n\n /*\n * **거래 식별자는 오더 식별자와 다른 것이다.**\n *\n * 전에는 둘이 한 칸(`orderId`)에 들어갔다. 그래서 한 오더의 이력이 색인 안에서 두 쪽으로\n * 갈라졌다 — 운영 델타는 `order-1`, EPCIS 사건은 `urn:epc:id:gdti:…` 로 적혀 어느 이름으로\n * 물어도 절반만 나왔다(실측 2026-09-05: 어휘가 섞인 행 10,004개, 오류는 나지 않음).\n *\n * 오더 사건은 두 값을 다 들고 있다. 그래서 이 칸이 **EPCIS 사건에서 오더로 건너가는 다리**가\n * 된다 — 「이 개체를 어느 레시피로 만들었나」가 그 다리로 답해진다.\n */\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Business transaction identifier (PO / SO / production order), promoted from the payload for indexed lookup of one transaction.' })\n bizTransaction?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Location identifier (read point, business location, or the plain location an operational delta carries), promoted from the payload for indexed filtering.' })\n locationId?: string\n\n @Column({ length: 255, nullable: true })\n @Field({ nullable: true, description: 'Equipment identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server. The column keeps the legacy name `moverId`: the journal is append-only history and mixing two names for the same fact across time is worse than an outdated name. Renaming belongs with event-schema versioning.' })\n moverId?: string\n\n /**\n * **Whether the sender declared this fact as having actually happened** — the envelope's\n * `provenance`, promoted here.\n *\n * ── Why a column (measured 2026-09-09) ──────────────────────────────────\n * This table held 1,134 rows, all of them generator output, and **there was nowhere to ask\n * about it**:\n *\n * ```\n * select count(*) from twin_events 1134\n * select count(*) where payload like '%SIM-%' 1134\n * select count(*) where order_id like 'WO-SIM-%' 558 <- half of them\n * ```\n *\n * The only signal was the sender's naming convention (`SIM-`), and the column that convention\n * reaches was empty on half the rows. While it sits inside `payload`, none of the five drivers\n * can answer \"count these without the invented ones\" — the same reason `action` was promoted.\n *\n * ── Three states, kept apart ────────────────────────────────────────────\n * ```\n * 'simulated' the sender said it was invented\n * 'actual' the sender said it happened\n * null **not declared** — which is not `actual`\n * ```\n *\n * Folding the last two together means a generator that omits the declaration has its output\n * counted as production, and nothing says so. Hence no default: an absent declaration is not\n * disguised as a valid-looking value (the discipline of `structureRev` and `lastWarningCount`).\n *\n * ── No index ────────────────────────────────────────────────────────────\n * Three possible values is low cardinality within one twin — the judgement already made for\n * `bizStep` and `locationId`. Performance folds arrive already narrowed by domain, instance and\n * time, so filtering after that is cheap.\n */\n @Column({ length: 16, nullable: true })\n @Field({ nullable: true, description: 'Whether the sending side declared this fact as actually having happened. Either \"actual\" or \"simulated\"; null means it was not declared, which is not the same as actual — a generator that omits it must not be counted as production.' })\n provenance?: string\n\n @Column({ type: 'simple-json', nullable: true })\n @Field(type => ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' })\n payload?: any\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Wall-clock timestamp when the record was persisted.' })\n createdAt?: Date\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/headless-twin",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.5",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh <heartyoh@hatiolab.com>",
|
|
@@ -27,12 +27,13 @@
|
|
|
27
27
|
"clean:shared": "rm -rf dist-shared tsconfig.shared.tsbuildinfo"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@operato/ops-contract": "^0.9.
|
|
31
|
-
"@operato/twin-kernel": "^0.11.
|
|
32
|
-
"@things-factory/auth-base": "^10.1.
|
|
33
|
-
"@things-factory/cache-service": "^10.1.
|
|
30
|
+
"@operato/ops-contract": "^0.9.17",
|
|
31
|
+
"@operato/twin-kernel": "^0.11.10",
|
|
32
|
+
"@things-factory/auth-base": "^10.1.4",
|
|
33
|
+
"@things-factory/cache-service": "^10.1.4",
|
|
34
34
|
"@things-factory/env": "^10.1.3",
|
|
35
|
-
"@things-factory/
|
|
35
|
+
"@things-factory/ingest": "^10.1.5",
|
|
36
|
+
"@things-factory/shell": "^10.1.4"
|
|
36
37
|
},
|
|
37
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "d7e60939822ef18e5311311746b2abd28f169173"
|
|
38
39
|
}
|
|
@@ -164,6 +164,23 @@ export function canonicalRulesForTest(): AdapterRule[] {
|
|
|
164
164
|
* **매핑 룰(`TWIN_INGEST_RULES`)은 여기 남는다** — 이 모양을 EPCIS 사건으로 옮기는 정책이지 모양이 아니다.
|
|
165
165
|
*/
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* 거절 목록의 사본을 원본 참조로 되돌린다 — **훅이 객체 동일성으로 봉투와 짝짓기 때문이다.**
|
|
169
|
+
*
|
|
170
|
+
* 지도에 없는 것은 그대로 둔다. 사본을 만들지 않는 갈래(에너지·운영·어휘)의 거절이 그렇고, 그것들은
|
|
171
|
+
* 이미 원본을 들고 있다.
|
|
172
|
+
*/
|
|
173
|
+
function toOriginals(
|
|
174
|
+
originalOf: Map<unknown, unknown>,
|
|
175
|
+
rejected: { record: unknown; errors: string[] }[]
|
|
176
|
+
): { record: unknown; errors: string[] }[] {
|
|
177
|
+
if (!originalOf.size) return rejected
|
|
178
|
+
return rejected.map(r => {
|
|
179
|
+
const original = originalOf.get(r.record)
|
|
180
|
+
return original === undefined ? r : { record: original, errors: r.errors }
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
/** 정규 레코드[] → 검증된 CanonicalEnvelope[] (+ 거부분). 순수 함수. 단일→객체도 배열로 정규화. */
|
|
168
185
|
export function ingestCanonicalRecords(
|
|
169
186
|
records:
|
|
@@ -200,6 +217,34 @@ export function ingestCanonicalRecords(
|
|
|
200
217
|
scope?: { scopeId?: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined }
|
|
201
218
|
): CanonicalIngestResult {
|
|
202
219
|
const arr = Array.isArray(records) ? records : records ? [records] : []
|
|
220
|
+
|
|
221
|
+
/*
|
|
222
|
+
* ── 사본을 만들었으면 나가는 길에 되돌린다 (2026-09-08 실측) ─────────────────
|
|
223
|
+
*
|
|
224
|
+
* 아래에서 레코드마다 `{ ...r, eventTime, sourceType }` 로 **새 객체**를 만들어 계약의 유입
|
|
225
|
+
* 함수에 넘긴다. 그 함수들은 거절한 것을 `{ record, errors }` 로 내는데, 그 `record` 는 **사본**이다.
|
|
226
|
+
*
|
|
227
|
+
* 훅은 거절된 레코드를 봉투와 짝지어 `eventId` 를 실어 보낸다(§`rejectedForCaller`). 그 짝은
|
|
228
|
+
* **객체 동일성**으로 짓는다 — 값으로 비교하면 같은 모양의 레코드 둘이 서로의 id 를 가져가기
|
|
229
|
+
* 때문이다. 그런데 사본은 원본과 다른 객체라 그 지도에 없고, 결과가 이렇게 나왔다.
|
|
230
|
+
*
|
|
231
|
+
* ```
|
|
232
|
+
* 보낸 것 {"eventId":"probe-eventid-check-001", "record":{"kind":"transformation"}}
|
|
233
|
+
* 온 것 rejected: [{ record: {kind:"transformation", sourceType:"twin"}, errors:[…] }]
|
|
234
|
+
* ↑ eventId 가 없다. 보내는 쪽이 어느 행이 떨어졌는지 모른다
|
|
235
|
+
* ```
|
|
236
|
+
*
|
|
237
|
+
* 보내는 쪽은 그래서 정산을 못 하고 큐가 안 움직인다(plant 실측: `PENDING 719`). 사실은 하나도
|
|
238
|
+
* 안 잃지만 나아가지도 않는다.
|
|
239
|
+
*
|
|
240
|
+
* **사본을 만든 자리가 되돌린다.** 훅이 값 비교로 내려가면 오늘 그 짝짓기를 객체 동일성으로 고른
|
|
241
|
+
* 이유가 없어지고, 계약의 유입 함수들이 원본을 들고 있게 바꾸면 그쪽이 사본을 만들 자유를 잃는다.
|
|
242
|
+
*
|
|
243
|
+
* 그리고 이 결함이 시험에 안 걸린 이유를 적어 둔다 — `hook-rejected-shape.test.ts` 가
|
|
244
|
+
* `rejectedForCaller` 에 **양쪽 다 같은 객체**를 직접 넘긴다. 순수 시험은 그 사이에 누가 사본을
|
|
245
|
+
* 만드는지 보지 않는다.
|
|
246
|
+
*/
|
|
247
|
+
const originalOf = new Map<unknown, unknown>()
|
|
203
248
|
/*
|
|
204
249
|
* ── **다섯째 어휘는 사건이 아니다** (2026-08-28) ────────────────────────────
|
|
205
250
|
*
|
|
@@ -339,7 +384,11 @@ export function ingestCanonicalRecords(
|
|
|
339
384
|
: isAggregationRecord(r)
|
|
340
385
|
? 'twin-aggregation'
|
|
341
386
|
: 'twin'
|
|
342
|
-
})),
|
|
387
|
+
})).map((copy, i) => {
|
|
388
|
+
/* 사본 → 원본. 거절이 사본을 들고 나오면 이 지도로 되돌린다(§ 위 머리말). */
|
|
389
|
+
originalOf.set(copy, epcis[i])
|
|
390
|
+
return copy
|
|
391
|
+
}),
|
|
343
392
|
TWIN_INGEST_RULES,
|
|
344
393
|
/*
|
|
345
394
|
* **레코드가 시각을 말하면 그것을 쓴다** (2026-08-23).
|
|
@@ -417,7 +466,7 @@ export function ingestCanonicalRecords(
|
|
|
417
466
|
...tariffBasisResult.accepted,
|
|
418
467
|
...operationalResult.accepted
|
|
419
468
|
],
|
|
420
|
-
rejected: [
|
|
469
|
+
rejected: toOriginals(originalOf, [
|
|
421
470
|
...epcisResult.rejected,
|
|
422
471
|
...energyResult.rejected,
|
|
423
472
|
...equipmentResult.rejected,
|
|
@@ -429,7 +478,7 @@ export function ingestCanonicalRecords(
|
|
|
429
478
|
...generationPeriodResult.rejected,
|
|
430
479
|
...operationalResult.rejected,
|
|
431
480
|
...master.rejected
|
|
432
|
-
],
|
|
481
|
+
]),
|
|
433
482
|
/* 사건이 아닌 것은 따로 낸다 — 부르는 쪽이 상태 세우는 문으로 보낸다(저널로 가지 않게). */
|
|
434
483
|
masterData: master.accepted,
|
|
435
484
|
/*
|