@things-factory/headless-twin 10.0.5 → 10.0.6

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 (43) hide show
  1. package/dist-server/engine/kpi-fold.js +24 -49
  2. package/dist-server/engine/kpi-fold.js.map +1 -1
  3. package/dist-server/engine/kpi-query.d.ts +7 -0
  4. package/dist-server/engine/kpi-query.js +11 -0
  5. package/dist-server/engine/kpi-query.js.map +1 -1
  6. package/dist-server/engine/twin-engine.d.ts +29 -2
  7. package/dist-server/engine/twin-engine.js +78 -21
  8. package/dist-server/engine/twin-engine.js.map +1 -1
  9. package/dist-server/engine/warm-start.d.ts +39 -0
  10. package/dist-server/engine/warm-start.js +37 -0
  11. package/dist-server/engine/warm-start.js.map +1 -0
  12. package/dist-server/index.js +8 -0
  13. package/dist-server/index.js.map +1 -1
  14. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  15. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  16. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  17. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  18. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  19. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  20. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  21. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  22. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  23. package/dist-server/service/twin-event/twin-event.d.ts +5 -0
  24. package/dist-server/service/twin-event/twin-event.js +45 -0
  25. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  26. package/dist-server/service/twin-journal/twin-journal-query.d.ts +19 -0
  27. package/dist-server/service/twin-journal/twin-journal-query.js +74 -0
  28. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  29. package/dist-server/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +6 -6
  31. package/server/engine/kpi-fold.ts +23 -52
  32. package/server/engine/kpi-query.ts +11 -0
  33. package/server/engine/twin-engine.ts +95 -28
  34. package/server/engine/warm-start.ts +53 -0
  35. package/server/index.ts +9 -0
  36. package/server/service/twin-event/backfill-keys.ts +72 -0
  37. package/server/service/twin-event/twin-event-keys.ts +102 -0
  38. package/server/service/twin-event/twin-event-type.ts +27 -0
  39. package/server/service/twin-event/twin-event.ts +48 -0
  40. package/server/service/twin-journal/twin-journal-query.ts +79 -3
  41. package/test/kpi-fold.test.ts +22 -0
  42. package/test/twin-event-keys.test.ts +108 -0
  43. package/test/warm-start.test.ts +78 -0
@@ -0,0 +1,39 @@
1
+ /** 커널에 심을 관측 상태 — 구조가 아니라 "무엇이 어디에 얼마나". */
2
+ export interface ObservedSeed {
3
+ nodes: unknown[];
4
+ items: unknown[];
5
+ movers: unknown[];
6
+ }
7
+ export type WarmStartPlan = {
8
+ action: 'hydrate';
9
+ seed: ObservedSeed;
10
+ itemCount: number;
11
+ moverCount: number;
12
+ }
13
+ /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 심으면 측정이 오염된다. */
14
+ | {
15
+ action: 'skip';
16
+ reason: 'bench';
17
+ }
18
+ /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */
19
+ | {
20
+ action: 'skip';
21
+ reason: 'no-state';
22
+ }
23
+ /** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */
24
+ | {
25
+ action: 'skip';
26
+ reason: 'unsupported';
27
+ };
28
+ /**
29
+ * 무엇을 할지 정한다.
30
+ *
31
+ * **오더는 의도적으로 심지 않는다.** 커널의 관측 주입은 오더에 requested/fulfilled/lines 를 요구하는데
32
+ * 스냅샷의 오더에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이므로
33
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작하는 편이 정직하다.
34
+ */
35
+ export declare function planWarmStart(state: {
36
+ nodes?: unknown[];
37
+ items?: unknown[];
38
+ movers?: unknown[];
39
+ } | null | undefined, purpose: string | undefined, canHydrate: boolean): WarmStartPlan;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ /*
3
+ * 웜스타트 판정 — **순수**. "기동하는 커널에 직전 상태를 심을 것인가, 심는다면 무엇을" 만 정한다.
4
+ * 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다.
5
+ *
6
+ * ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────
7
+ * 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 심어야 할 때 안 심으면 **있는 재고가 0 으로**
8
+ * 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지
9
+ * 말아야 할 벤치에 심으면 **용량 측정이 오염된다.** 둘 다 조용히 틀리는 종류라 규칙을 고정한다.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.planWarmStart = planWarmStart;
13
+ /**
14
+ * 무엇을 할지 정한다.
15
+ *
16
+ * **오더는 의도적으로 심지 않는다.** 커널의 관측 주입은 오더에 requested/fulfilled/lines 를 요구하는데
17
+ * 스냅샷의 오더에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이므로
18
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작하는 편이 정직하다.
19
+ */
20
+ function planWarmStart(state, purpose, canHydrate) {
21
+ /* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 심지 않는다는 사실이 바뀌지 않는다. */
22
+ if (purpose === 'bench')
23
+ return { action: 'skip', reason: 'bench' };
24
+ if (!state)
25
+ return { action: 'skip', reason: 'no-state' };
26
+ const nodes = state.nodes ?? [];
27
+ const items = state.items ?? [];
28
+ const movers = state.movers ?? [];
29
+ /* 셋 다 비었으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
30
+ if (nodes.length === 0 && items.length === 0 && movers.length === 0)
31
+ return { action: 'skip', reason: 'no-state' };
32
+ /* 지원 여부는 마지막에 본다 — 심을 게 있는데 못 심는 상황이라야 경고할 값어치가 있다. */
33
+ if (!canHydrate)
34
+ return { action: 'skip', reason: 'unsupported' };
35
+ return { action: 'hydrate', seed: { nodes, items, movers }, itemCount: items.length, moverCount: movers.length };
36
+ }
37
+ //# sourceMappingURL=warm-start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warm-start.js","sourceRoot":"","sources":["../../server/engine/warm-start.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;;AAyBH,sCAmBC;AA1BD;;;;;;GAMG;AACH,SAAgB,aAAa,CAC3B,KAAsF,EACtF,OAA2B,EAC3B,UAAmB;IAEnB,sDAAsD;IACtD,IAAI,OAAO,KAAK,OAAO;QAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,CAAA;IACnE,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,CAAA;IAEzD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,EAAE,CAAA;IAC/B,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,EAAE,CAAA;IAC/B,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,IAAI,EAAE,CAAA;IACjC,6CAA6C;IAC7C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,CAAA;IAElH,uDAAuD;IACvD,IAAI,CAAC,UAAU;QAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,CAAA;IAEjE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,KAAK,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,CAAA;AAClH,CAAC","sourcesContent":["/*\n * 웜스타트 판정 — **순수**. \"기동하는 커널에 직전 상태를 심을 것인가, 심는다면 무엇을\" 만 정한다.\n * 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다.\n *\n * ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────\n * 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 심어야 할 때 안 심으면 **있는 재고가 0 으로**\n * 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지\n * 말아야 할 벤치에 심으면 **용량 측정이 오염된다.** 둘 다 조용히 틀리는 종류라 규칙을 고정한다.\n */\n\n/** 커널에 심을 관측 상태 — 구조가 아니라 \"무엇이 어디에 얼마나\". */\nexport interface ObservedSeed {\n nodes: unknown[]\n items: unknown[]\n movers: unknown[]\n}\n\nexport type WarmStartPlan =\n | { action: 'hydrate'; seed: ObservedSeed; itemCount: number; moverCount: number }\n /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 심으면 측정이 오염된다. */\n | { action: 'skip'; reason: 'bench' }\n /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */\n | { action: 'skip'; reason: 'no-state' }\n /** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */\n | { action: 'skip'; reason: 'unsupported' }\n\n/**\n * 무엇을 할지 정한다.\n *\n * **오더는 의도적으로 심지 않는다.** 커널의 관측 주입은 오더에 requested/fulfilled/lines 를 요구하는데\n * 스냅샷의 오더에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이므로\n * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작하는 편이 정직하다.\n */\nexport function planWarmStart(\n state: { nodes?: unknown[]; items?: unknown[]; movers?: unknown[] } | null | undefined,\n purpose: string | undefined,\n canHydrate: boolean\n): WarmStartPlan {\n /* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 심지 않는다는 사실이 바뀌지 않는다. */\n if (purpose === 'bench') return { action: 'skip', reason: 'bench' }\n if (!state) return { action: 'skip', reason: 'no-state' }\n\n const nodes = state.nodes ?? []\n const items = state.items ?? []\n const movers = state.movers ?? []\n /* 셋 다 비었으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */\n if (nodes.length === 0 && items.length === 0 && movers.length === 0) return { action: 'skip', reason: 'no-state' }\n\n /* 지원 여부는 마지막에 본다 — 심을 게 있는데 못 심는 상황이라야 경고할 값어치가 있다. */\n if (!canHydrate) return { action: 'skip', reason: 'unsupported' }\n\n return { action: 'hydrate', seed: { nodes, items, movers }, itemCount: items.length, moverCount: movers.length }\n}\n"]}
@@ -5,6 +5,7 @@ tslib_1.__exportStar(require("./engine/index.js"), exports);
5
5
  tslib_1.__exportStar(require("./service/index.js"), exports);
6
6
  require("./routes.js");
7
7
  const index_js_1 = require("./engine/index.js");
8
+ const backfill_keys_js_1 = require("./service/twin-event/backfill-keys.js");
8
9
  /* 모듈 부팅 — 영속 인스턴스 복구 훅(향후). 지금은 명시 start(mutation/부팅 설정)로 인스턴스 생성. */
9
10
  process.on('bootstrap-module-start', async ({ app, config, client }) => {
10
11
  try {
@@ -14,5 +15,12 @@ process.on('bootstrap-module-start', async ({ app, config, client }) => {
14
15
  catch (ex) {
15
16
  console.error('Headless Twin host failed to start.', ex);
16
17
  }
18
+ /*
19
+ * 승격 검색 키 백필 — 컬럼이 생기기 전에 쌓인 저널을 채운다. 멱등·재개 가능이라 매 기동 불러도
20
+ * 채울 게 없으면 조각 한 번 읽고 끝난다(로그도 남기지 않는다).
21
+ * 기동을 막지 않는다 — 저널이 크면 오래 걸릴 수 있고, 그동안 트윈은 정상 동작해야 한다.
22
+ * 실패해도 서비스는 계속된다: 못 채운 만큼 **과거 이력 검색이 덜 나올 뿐**이므로 조용히 삼키지 않고 알린다.
23
+ */
24
+ (0, backfill_keys_js_1.backfillTwinEventKeys)().catch(ex => console.error('twin-event key backfill failed — search over older journal rows will be incomplete.', ex));
17
25
  });
18
26
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;AAAA,4DAAiC;AACjC,6DAAkC;AAElC,uBAAoB;AAEpB,gDAA8C;AAE9C,sEAAsE;AACtE,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAO,EAAE,EAAE;IACjF,IAAI,CAAC;QACH,MAAM,qBAAU,CAAC,SAAS,EAAE,CAAA;QAC5B,OAAO,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAA;IAChD,CAAC;IAAC,OAAO,EAAE,EAAE,CAAC;QACZ,OAAO,CAAC,KAAK,CAAC,qCAAqC,EAAE,EAAE,CAAC,CAAA;IAC1D,CAAC;AACH,CAAC,CAAC,CAAA","sourcesContent":["export * from './engine/index.js'\nexport * from './service/index.js'\n\nimport './routes.js'\n\nimport { TwinEngine } from './engine/index.js'\n\n/* 모듈 부팅 — 영속 인스턴스 복구 훅(향후). 지금은 명시 start(mutation/부팅 설정)로 인스턴스 생성. */\nprocess.on('bootstrap-module-start' as any, async ({ app, config, client }: any) => {\n try {\n await TwinEngine.bootstrap()\n console.log('Headless Twin host has started.')\n } catch (ex) {\n console.error('Headless Twin host failed to start.', ex)\n }\n})\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;AAAA,4DAAiC;AACjC,6DAAkC;AAElC,uBAAoB;AAEpB,gDAA8C;AAC9C,4EAA6E;AAE7E,sEAAsE;AACtE,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAO,EAAE,EAAE;IACjF,IAAI,CAAC;QACH,MAAM,qBAAU,CAAC,SAAS,EAAE,CAAA;QAC5B,OAAO,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAA;IAChD,CAAC;IAAC,OAAO,EAAE,EAAE,CAAC;QACZ,OAAO,CAAC,KAAK,CAAC,qCAAqC,EAAE,EAAE,CAAC,CAAA;IAC1D,CAAC;IAED;;;;;OAKG;IACH,IAAA,wCAAqB,GAAE,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,qFAAqF,EAAE,EAAE,CAAC,CAAC,CAAA;AAC/I,CAAC,CAAC,CAAA","sourcesContent":["export * from './engine/index.js'\nexport * from './service/index.js'\n\nimport './routes.js'\n\nimport { TwinEngine } from './engine/index.js'\nimport { backfillTwinEventKeys } from './service/twin-event/backfill-keys.js'\n\n/* 모듈 부팅 — 영속 인스턴스 복구 훅(향후). 지금은 명시 start(mutation/부팅 설정)로 인스턴스 생성. */\nprocess.on('bootstrap-module-start' as any, async ({ app, config, client }: any) => {\n try {\n await TwinEngine.bootstrap()\n console.log('Headless Twin host has started.')\n } catch (ex) {\n console.error('Headless Twin host failed to start.', ex)\n }\n\n /*\n * 승격 검색 키 백필 — 컬럼이 생기기 전에 쌓인 저널을 채운다. 멱등·재개 가능이라 매 기동 불러도\n * 채울 게 없으면 조각 한 번 읽고 끝난다(로그도 남기지 않는다).\n * 기동을 막지 않는다 — 저널이 크면 오래 걸릴 수 있고, 그동안 트윈은 정상 동작해야 한다.\n * 실패해도 서비스는 계속된다: 못 채운 만큼 **과거 이력 검색이 덜 나올 뿐**이므로 조용히 삼키지 않고 알린다.\n */\n backfillTwinEventKeys().catch(ex => console.error('twin-event key backfill failed — search over older journal rows will be incomplete.', ex))\n})\n"]}
@@ -0,0 +1,11 @@
1
+ export interface BackfillResult {
2
+ /** 실제로 갱신한 행 수. */
3
+ updated: number;
4
+ /** 훑었지만 payload 가 없어 채울 수 없던 행 수 — 0 이 아니면 인제스트 쪽을 봐야 한다. */
5
+ skipped: number;
6
+ }
7
+ /**
8
+ * 남은 행을 전부 채운다. 진행 상황을 로그로 남긴다 — 조용히 오래 도는 작업은
9
+ * 멈춘 것과 구분되지 않는다.
10
+ */
11
+ export declare function backfillTwinEventKeys(): Promise<BackfillResult>;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.backfillTwinEventKeys = backfillTwinEventKeys;
4
+ const typeorm_1 = require("typeorm");
5
+ const shell_1 = require("@things-factory/shell");
6
+ const twin_event_js_1 = require("./twin-event.js");
7
+ const twin_event_keys_js_1 = require("./twin-event-keys.js");
8
+ /*
9
+ * 승격 검색 키 백필 — 컬럼이 생기기 **전에** 쌓인 저널 행을 채운다.
10
+ *
11
+ * 이게 없으면 검색은 "오늘부터의 이력" 만 찾는다. 사용자에게는 그냥 **과거가 없는 것으로 보이고**,
12
+ * 그건 이번에 고치려던 결함(조용히 빠진 데이터)과 정확히 같은 종류다.
13
+ *
14
+ * 성질:
15
+ * · 멱등 — 이미 채워진 행은 건드리지 않는다(bizStep IS NULL 인 것만 집는다).
16
+ * · 재개 가능 — 중간에 죽어도 다음 기동에서 남은 것부터 이어간다.
17
+ * · 조각내서 — 한 번에 다 읽지 않는다. 저널은 크다는 전제로 만든다.
18
+ * · payload 는 손대지 않는다 — 정본은 그대로 두고 파생 색인만 채운다.
19
+ *
20
+ * 아주 큰 운영 저널이라면 기동 시 백그라운드보다 **마이그레이션으로 한 번** 도는 편이 낫다.
21
+ * 이 함수를 그대로 부르면 되므로 경로는 하나다.
22
+ */
23
+ const CHUNK = 1000;
24
+ /**
25
+ * 남은 행을 전부 채운다. 진행 상황을 로그로 남긴다 — 조용히 오래 도는 작업은
26
+ * 멈춘 것과 구분되지 않는다.
27
+ */
28
+ async function backfillTwinEventKeys() {
29
+ const repo = (0, shell_1.getRepository)(twin_event_js_1.TwinEvent);
30
+ let updated = 0;
31
+ let skipped = 0;
32
+ let round = 0;
33
+ for (;;) {
34
+ /* bizStep 은 이벤트 타입에서라도 유추되므로 **정상 인제스트라면 반드시 채워진다** —
35
+ * 즉 NULL 은 "승격 이전 행" 의 확실한 표식이다. epc 로 판정하면 품목 없는 이벤트를
36
+ * 매번 다시 집어 무한히 돈다. */
37
+ const rows = await repo.find({ where: { bizStep: (0, typeorm_1.IsNull)() }, take: CHUNK, order: { revision: 'ASC' } });
38
+ if (rows.length === 0)
39
+ break;
40
+ const dirty = [];
41
+ for (const row of rows) {
42
+ if (!row.payload) {
43
+ skipped++;
44
+ continue;
45
+ }
46
+ Object.assign(row, (0, twin_event_keys_js_1.twinEventKeys)(row.payload));
47
+ dirty.push(row);
48
+ }
49
+ if (dirty.length)
50
+ await repo.save(dirty, { chunk: 500 });
51
+ updated += dirty.length;
52
+ /* payload 가 없는 행만 남으면 같은 조각을 영원히 다시 읽는다 — 진도가 없으면 멈춘다. */
53
+ if (dirty.length === 0)
54
+ break;
55
+ if (++round % 10 === 0)
56
+ console.log(`[twin-event backfill] ${updated} rows filled…`);
57
+ }
58
+ if (updated || skipped) {
59
+ console.log(`[twin-event backfill] done — ${updated} filled, ${skipped} skipped (no payload)`);
60
+ }
61
+ return { updated, skipped };
62
+ }
63
+ //# sourceMappingURL=backfill-keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backfill-keys.js","sourceRoot":"","sources":["../../../server/service/twin-event/backfill-keys.ts"],"names":[],"mappings":";;AAoCA,sDAmCC;AAvED,qCAAgC;AAEhC,iDAAqD;AAErD,mDAA2C;AAC3C,6DAAoD;AAEpD;;;;;;;;;;;;;;GAcG;AAEH,MAAM,KAAK,GAAG,IAAI,CAAA;AASlB;;;GAGG;AACI,KAAK,UAAU,qBAAqB;IACzC,MAAM,IAAI,GAAG,IAAA,qBAAa,EAAC,yBAAS,CAAC,CAAA;IACrC,IAAI,OAAO,GAAG,CAAC,CAAA;IACf,IAAI,OAAO,GAAG,CAAC,CAAA;IACf,IAAI,KAAK,GAAG,CAAC,CAAA;IAEb,SAAS,CAAC;QACR;;8BAEsB;QACtB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,OAAO,EAAE,IAAA,gBAAM,GAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAC,CAAA;QACvG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,MAAK;QAE5B,MAAM,KAAK,GAAgB,EAAE,CAAA;QAC7B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;gBACjB,OAAO,EAAE,CAAA;gBACT,SAAQ;YACV,CAAC;YACD,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,IAAA,kCAAa,EAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAA;YAC9C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACjB,CAAC;QACD,IAAI,KAAK,CAAC,MAAM;YAAE,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;QACxD,OAAO,IAAI,KAAK,CAAC,MAAM,CAAA;QAEvB,0DAA0D;QAC1D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,MAAK;QAE7B,IAAI,EAAE,KAAK,GAAG,EAAE,KAAK,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,yBAAyB,OAAO,eAAe,CAAC,CAAA;IACtF,CAAC;IAED,IAAI,OAAO,IAAI,OAAO,EAAE,CAAC;QACvB,OAAO,CAAC,GAAG,CAAC,gCAAgC,OAAO,YAAY,OAAO,uBAAuB,CAAC,CAAA;IAChG,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAA;AAC7B,CAAC","sourcesContent":["import { IsNull } from 'typeorm'\n\nimport { getRepository } from '@things-factory/shell'\n\nimport { TwinEvent } from './twin-event.js'\nimport { twinEventKeys } from './twin-event-keys.js'\n\n/*\n * 승격 검색 키 백필 — 컬럼이 생기기 **전에** 쌓인 저널 행을 채운다.\n *\n * 이게 없으면 검색은 \"오늘부터의 이력\" 만 찾는다. 사용자에게는 그냥 **과거가 없는 것으로 보이고**,\n * 그건 이번에 고치려던 결함(조용히 빠진 데이터)과 정확히 같은 종류다.\n *\n * 성질:\n * · 멱등 — 이미 채워진 행은 건드리지 않는다(bizStep IS NULL 인 것만 집는다).\n * · 재개 가능 — 중간에 죽어도 다음 기동에서 남은 것부터 이어간다.\n * · 조각내서 — 한 번에 다 읽지 않는다. 저널은 크다는 전제로 만든다.\n * · payload 는 손대지 않는다 — 정본은 그대로 두고 파생 색인만 채운다.\n *\n * 아주 큰 운영 저널이라면 기동 시 백그라운드보다 **마이그레이션으로 한 번** 도는 편이 낫다.\n * 이 함수를 그대로 부르면 되므로 경로는 하나다.\n */\n\nconst CHUNK = 1000\n\nexport interface BackfillResult {\n /** 실제로 갱신한 행 수. */\n updated: number\n /** 훑었지만 payload 가 없어 채울 수 없던 행 수 — 0 이 아니면 인제스트 쪽을 봐야 한다. */\n skipped: number\n}\n\n/**\n * 남은 행을 전부 채운다. 진행 상황을 로그로 남긴다 — 조용히 오래 도는 작업은\n * 멈춘 것과 구분되지 않는다.\n */\nexport async function backfillTwinEventKeys(): Promise<BackfillResult> {\n const repo = getRepository(TwinEvent)\n let updated = 0\n let skipped = 0\n let round = 0\n\n for (;;) {\n /* bizStep 은 이벤트 타입에서라도 유추되므로 **정상 인제스트라면 반드시 채워진다** —\n * 즉 NULL 은 \"승격 이전 행\" 의 확실한 표식이다. epc 로 판정하면 품목 없는 이벤트를\n * 매번 다시 집어 무한히 돈다. */\n const rows = await repo.find({ where: { bizStep: IsNull() }, take: CHUNK, order: { revision: 'ASC' } })\n if (rows.length === 0) break\n\n const dirty: TwinEvent[] = []\n for (const row of rows) {\n if (!row.payload) {\n skipped++\n continue\n }\n Object.assign(row, twinEventKeys(row.payload))\n dirty.push(row)\n }\n if (dirty.length) await repo.save(dirty, { chunk: 500 })\n updated += dirty.length\n\n /* payload 가 없는 행만 남으면 같은 조각을 영원히 다시 읽는다 — 진도가 없으면 멈춘다. */\n if (dirty.length === 0) break\n\n if (++round % 10 === 0) console.log(`[twin-event backfill] ${updated} rows filled…`)\n }\n\n if (updated || skipped) {\n console.log(`[twin-event backfill] done — ${updated} filled, ${skipped} skipped (no payload)`)\n }\n return { updated, skipped }\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */
2
+ export interface TwinEventKeys {
3
+ bizStep?: string;
4
+ epc?: string;
5
+ orderId?: string;
6
+ locationId?: string;
7
+ moverId?: string;
8
+ }
9
+ /** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
10
+ export declare function bizStepOf(envelope: any): string | undefined;
11
+ /**
12
+ * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).
13
+ *
14
+ * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라
15
+ * 전체를 저장해 두면 끝마디("402.2")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면
16
+ * URN 으로 찾는 경로가 사라진다.
17
+ */
18
+ export declare function epcOf(envelope: any): string | undefined;
19
+ /** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
20
+ export declare function orderOf(envelope: any): string | undefined;
21
+ /**
22
+ * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).
23
+ * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서
24
+ * 무엇을 했는지가 위치 축에서 통째로 사라진다.
25
+ */
26
+ export declare function locationOf(envelope: any): string | undefined;
27
+ /**
28
+ * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.
29
+ *
30
+ * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 "이 지게차가 오늘 무엇을 했나" 를
31
+ * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 훑는 수밖에 없다.
32
+ */
33
+ export declare function moverOf(envelope: any): string | undefined;
34
+ /** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */
35
+ export declare function twinEventKeys(envelope: any): TwinEventKeys;
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ /*
3
+ * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.
4
+ *
5
+ * ── 왜 승격하는가 ───────────────────────────────────────────────────────────
6
+ * 사용자가 저널에서 실제로 찾는 것은 "이 팔레트의 이력", "이 오더가 어디까지 갔나", "이 도크에서
7
+ * 무슨 일이 있었나" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.
8
+ * things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —
9
+ * 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온
10
+ * 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.
11
+ *
12
+ * 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —
13
+ * 이건 파생 색인이지 새로운 진실이 아니다).
14
+ *
15
+ * ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────
16
+ * 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면
17
+ * 반드시 어긋나고, 어긋난 색인은 "없는 것처럼 보이는 이벤트" 를 만든다 — 저널에서 가장 나쁜 결함이다.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.bizStepOf = bizStepOf;
21
+ exports.epcOf = epcOf;
22
+ exports.orderOf = orderOf;
23
+ exports.locationOf = locationOf;
24
+ exports.moverOf = moverOf;
25
+ exports.twinEventKeys = twinEventKeys;
26
+ /*
27
+ * 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.
28
+ * 넘치는 값이 오면 **조용히 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,
29
+ * 그 사실이 어디에도 안 남으면 아무도 모른다.
30
+ */
31
+ const MAX_KEY = 255;
32
+ function clip(v, field) {
33
+ if (v === undefined || v === null)
34
+ return undefined;
35
+ const s = String(v);
36
+ if (!s)
37
+ return undefined;
38
+ if (s.length <= MAX_KEY)
39
+ return s;
40
+ console.warn(`[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +
41
+ `search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`);
42
+ return s.slice(0, MAX_KEY);
43
+ }
44
+ /** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
45
+ function bizStepOf(envelope) {
46
+ const d = envelope?.data ?? envelope ?? {};
47
+ const tail = String(d.bizStep ?? '').split(':').pop();
48
+ return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined;
49
+ }
50
+ /**
51
+ * 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).
52
+ *
53
+ * 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라
54
+ * 전체를 저장해 두면 끝마디("402.2")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면
55
+ * URN 으로 찾는 경로가 사라진다.
56
+ */
57
+ function epcOf(envelope) {
58
+ const d = envelope?.data ?? envelope ?? {};
59
+ return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined;
60
+ }
61
+ /** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
62
+ function orderOf(envelope) {
63
+ const d = envelope?.data ?? envelope ?? {};
64
+ return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? undefined;
65
+ }
66
+ /**
67
+ * 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).
68
+ * 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서
69
+ * 무엇을 했는지가 위치 축에서 통째로 사라진다.
70
+ */
71
+ function locationOf(envelope) {
72
+ const d = envelope?.data ?? envelope ?? {};
73
+ return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined;
74
+ }
75
+ /**
76
+ * 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.
77
+ *
78
+ * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 "이 지게차가 오늘 무엇을 했나" 를
79
+ * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 훑는 수밖에 없다.
80
+ */
81
+ function moverOf(envelope) {
82
+ const d = envelope?.data ?? envelope ?? {};
83
+ return d.moverId ?? undefined;
84
+ }
85
+ /** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */
86
+ function twinEventKeys(envelope) {
87
+ return {
88
+ bizStep: clip(bizStepOf(envelope), 'bizStep'),
89
+ epc: clip(epcOf(envelope), 'epc'),
90
+ orderId: clip(orderOf(envelope), 'orderId'),
91
+ locationId: clip(locationOf(envelope), 'locationId'),
92
+ moverId: clip(moverOf(envelope), 'moverId')
93
+ };
94
+ }
95
+ //# sourceMappingURL=twin-event-keys.js.map
@@ -0,0 +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":["/*\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 bizStep?: string\n epc?: string\n orderId?: 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 console.warn(\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/** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */\nexport function orderOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? 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 moverOf(envelope: any): string | undefined {\n const d = envelope?.data ?? envelope ?? {}\n return d.moverId ?? undefined\n}\n\n/** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */\nexport function twinEventKeys(envelope: any): TwinEventKeys {\n return {\n bizStep: clip(bizStepOf(envelope), 'bizStep'),\n epc: clip(epcOf(envelope), 'epc'),\n orderId: clip(orderOf(envelope), 'orderId'),\n locationId: clip(locationOf(envelope), 'locationId'),\n moverId: clip(moverOf(envelope), 'moverId')\n }\n}\n"]}
@@ -0,0 +1,6 @@
1
+ import { TwinEvent } from './twin-event.js';
2
+ export declare class TwinEventList {
3
+ items: TwinEvent[];
4
+ total: number;
5
+ nextCursor?: string;
6
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TwinEventList = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const type_graphql_1 = require("type-graphql");
6
+ const twin_event_js_1 = require("./twin-event.js");
7
+ /*
8
+ * 저널 목록 반환형 — things-factory 표준 `{ items, total }`(AttributeSetList·DomainList 등과 동형).
9
+ *
10
+ * `total` 이 이 타입의 존재 이유다. 기존 `twinEvents` 는 배열만 돌려줘서 **화면이 자기가 전체를 받은
11
+ * 건지 잘린 건지 알 방법이 없었다** — 그래서 리스트가 조용히 잘린 채로 "이게 전부" 처럼 보였다.
12
+ * 총건수를 함께 주면 화면은 "48 / 12,904" 라고 정직하게 말할 수 있고, 사용자는 좁혀야 한다는 걸 안다.
13
+ */
14
+ let TwinEventList = class TwinEventList {
15
+ };
16
+ exports.TwinEventList = TwinEventList;
17
+ tslib_1.__decorate([
18
+ (0, type_graphql_1.Field)(type => [twin_event_js_1.TwinEvent], { description: 'The events on this page, ordered by the requested sorting (revision descending by default).' }),
19
+ tslib_1.__metadata("design:type", Array)
20
+ ], TwinEventList.prototype, "items", void 0);
21
+ tslib_1.__decorate([
22
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Total number of events matching the filters, ignoring pagination. Lets the caller show an honest "shown of total" count instead of silently truncating.' }),
23
+ tslib_1.__metadata("design:type", Number)
24
+ ], TwinEventList.prototype, "total", void 0);
25
+ tslib_1.__decorate([
26
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Opaque cursor for the next page. Pass it back as pagination.after to continue exactly where this page ended, without the duplicate or skipped rows that offset paging produces on a journal that keeps growing at the head. Null when this page is the last one.' }),
27
+ tslib_1.__metadata("design:type", String)
28
+ ], TwinEventList.prototype, "nextCursor", void 0);
29
+ exports.TwinEventList = TwinEventList = tslib_1.__decorate([
30
+ (0, type_graphql_1.ObjectType)({ description: 'A page of twin journal events together with the total number of matching records.' })
31
+ ], TwinEventList);
32
+ //# sourceMappingURL=twin-event-type.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"twin-event-type.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event-type.ts"],"names":[],"mappings":";;;;AAAA,+CAAqD;AAErD,mDAA2C;AAE3C;;;;;;GAMG;AAEI,IAAM,aAAa,GAAnB,MAAM,aAAa;CAczB,CAAA;AAdY,sCAAa;AAExB;IADC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,CAAC,yBAAS,CAAC,EAAE,EAAE,WAAW,EAAE,6FAA6F,EAAE,CAAC;;4CACzH;AAGlB;IADC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,yJAAyJ,EAAE,CAAC;;4CAClL;AAQb;IADC,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,kQAAkQ,EAAE,CAAC;;iDACxR;wBAbR,aAAa;IADzB,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,mFAAmF,EAAE,CAAC;GACpG,aAAa,CAczB","sourcesContent":["import { Field, Int, ObjectType } from 'type-graphql'\n\nimport { TwinEvent } from './twin-event.js'\n\n/*\n * 저널 목록 반환형 — things-factory 표준 `{ items, total }`(AttributeSetList·DomainList 등과 동형).\n *\n * `total` 이 이 타입의 존재 이유다. 기존 `twinEvents` 는 배열만 돌려줘서 **화면이 자기가 전체를 받은\n * 건지 잘린 건지 알 방법이 없었다** — 그래서 리스트가 조용히 잘린 채로 \"이게 전부\" 처럼 보였다.\n * 총건수를 함께 주면 화면은 \"48 / 12,904\" 라고 정직하게 말할 수 있고, 사용자는 좁혀야 한다는 걸 안다.\n */\n@ObjectType({ description: 'A page of twin journal events together with the total number of matching records.' })\nexport class TwinEventList {\n @Field(type => [TwinEvent], { description: 'The events on this page, ordered by the requested sorting (revision descending by default).' })\n items: TwinEvent[]\n\n @Field(type => Int, { description: 'Total number of events matching the filters, ignoring pagination. Lets the caller show an honest \"shown of total\" count instead of silently truncating.' })\n total: number\n\n /*\n * 다음 페이지 커서. 저널은 **머리에 계속 쌓이는 목록**이라 offset 으로 뒤를 읽으면\n * 읽는 사이 들어온 이벤트만큼 밀려 본 행이 또 나오거나 못 본 행이 사라진다.\n * 이 값을 그대로 다음 요청의 `pagination.after` 로 돌려주면 그 문제가 없다.\n */\n @Field({ nullable: true, description: 'Opaque cursor for the next page. Pass it back as pagination.after to continue exactly where this page ended, without the duplicate or skipped rows that offset paging produces on a journal that keeps growing at the head. Null when this page is the last one.' })\n nextCursor?: string\n}\n"]}
@@ -8,6 +8,11 @@ export declare class TwinEvent {
8
8
  eventType?: string;
9
9
  revision?: number;
10
10
  eventTime?: string;
11
+ bizStep?: string;
12
+ epc?: string;
13
+ orderId?: string;
14
+ locationId?: string;
15
+ moverId?: string;
11
16
  payload?: any;
12
17
  createdAt?: Date;
13
18
  }
@@ -12,6 +12,22 @@ const shell_1 = require("@things-factory/shell");
12
12
  * payload 는 simple-json(멀티DB 이식 — postgres/mysql/sqlite/mssql/oracle 공통, DB-specific JSON 타입 금지).
13
13
  * (CLAUDE.md: 모든 @ObjectType/@Field 는 영문 description 필수.)
14
14
  */
15
+ /*
16
+ * ── 인덱스 설계 (2026-07-31) ────────────────────────────────────────────────
17
+ * 이 표는 **인제스트 경로의 뜨거운 append-only 테이블**이다. 인덱스 하나하나가 쓰기 증폭이므로
18
+ * "있으면 좋을" 인덱스를 붙이지 않는다. 실제 질의 패턴에 대응하는 것만 둔다.
19
+ *
20
+ * ix_0 (domain, instanceId, revision) 원장 기본 정렬·커서 페이징·replay(ASC 주사)
21
+ * ix_1 (domain, instanceId, eventTime) 시각 커서(untilTime)·시간창 KPI — 거의 모든 조회가 탄다
22
+ * ix_2 (domain, instanceId, eventType, revision) 타입 필터 + 정렬 동시 충족(스케줄 화면 task/equipment)
23
+ * ix_3 (domain, instanceId, epc) "이 물건의 이력" — Entity360 의 본질 질문
24
+ * ix_4 (domain, instanceId, orderId) "이 오더가 어디까지 갔나"
25
+ *
26
+ * bizStep·locationId·moverId 는 컬럼만 두고 인덱스는 두지 않는다 — 한 트윈 안에서 카디널리티가
27
+ * 낮아(업무단계 몇 개, 위치 수백, 설비 수십) (domain,instanceId) 로 이미 좁혀진 뒤의 잔여 필터로
28
+ * 충분하고, 뜨거운 표에 인덱스를 더 얹을 값어치가 없다. 저널 하나가 아주 커져서 이 축들의 조회가
29
+ * 느려지면 그때 측정을 근거로 인덱스를 추가할 일이지, 지레 얹어 쓰기를 무겁게 할 일은 아니다.
30
+ */
15
31
  let TwinEvent = class TwinEvent {
16
32
  };
17
33
  exports.TwinEvent = TwinEvent;
@@ -54,6 +70,31 @@ tslib_1.__decorate([
54
70
  (0, type_graphql_1.Field)({ nullable: true, description: 'Simulation event time (ISO 8601).' }),
55
71
  tslib_1.__metadata("design:type", String)
56
72
  ], TwinEvent.prototype, "eventTime", void 0);
73
+ tslib_1.__decorate([
74
+ (0, typeorm_1.Column)({ length: 255, nullable: true }),
75
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Business step (CBV bizStep tail), promoted from the payload for indexed filtering.' }),
76
+ tslib_1.__metadata("design:type", String)
77
+ ], TwinEvent.prototype, "bizStep", void 0);
78
+ tslib_1.__decorate([
79
+ (0, typeorm_1.Column)({ length: 255, nullable: true }),
80
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Item identifier (EPC / EPC class / parent id), promoted from the payload for indexed lookup of one item history.' }),
81
+ tslib_1.__metadata("design:type", String)
82
+ ], TwinEvent.prototype, "epc", void 0);
83
+ tslib_1.__decorate([
84
+ (0, typeorm_1.Column)({ length: 255, nullable: true }),
85
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Business transaction identifier (PO / SO), promoted from the payload for indexed lookup of one order history.' }),
86
+ tslib_1.__metadata("design:type", String)
87
+ ], TwinEvent.prototype, "orderId", void 0);
88
+ tslib_1.__decorate([
89
+ (0, typeorm_1.Column)({ length: 255, nullable: true }),
90
+ (0, type_graphql_1.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.' }),
91
+ tslib_1.__metadata("design:type", String)
92
+ ], TwinEvent.prototype, "locationId", void 0);
93
+ tslib_1.__decorate([
94
+ (0, typeorm_1.Column)({ length: 255, nullable: true }),
95
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Equipment or mover identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server.' }),
96
+ tslib_1.__metadata("design:type", String)
97
+ ], TwinEvent.prototype, "moverId", void 0);
57
98
  tslib_1.__decorate([
58
99
  (0, typeorm_1.Column)({ type: 'simple-json', nullable: true }),
59
100
  (0, type_graphql_1.Field)(type => shell_1.ScalarObject, { nullable: true, description: 'Raw canonical envelope (EPCIS event or operational delta) as JSON.' }),
@@ -67,6 +108,10 @@ tslib_1.__decorate([
67
108
  exports.TwinEvent = TwinEvent = tslib_1.__decorate([
68
109
  (0, typeorm_1.Entity)(),
69
110
  (0, typeorm_1.Index)('ix_twin_event_0', (e) => [e.domain, e.instanceId, e.revision], { unique: false }),
111
+ (0, typeorm_1.Index)('ix_twin_event_1', (e) => [e.domain, e.instanceId, e.eventTime], { unique: false }),
112
+ (0, typeorm_1.Index)('ix_twin_event_2', (e) => [e.domain, e.instanceId, e.eventType, e.revision], { unique: false }),
113
+ (0, typeorm_1.Index)('ix_twin_event_3', (e) => [e.domain, e.instanceId, e.epc], { unique: false }),
114
+ (0, typeorm_1.Index)('ix_twin_event_4', (e) => [e.domain, e.instanceId, e.orderId], { unique: false }),
70
115
  (0, type_graphql_1.ObjectType)({ description: 'Append-only twin event journal record (EPCIS event or operational delta).' })
71
116
  ], TwinEvent);
72
117
  //# sourceMappingURL=twin-event.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"twin-event.js","sourceRoot":"","sources":["../../../server/service/twin-event/twin-event.ts"],"names":[],"mappings":";;;;AAAA,qCAAgH;AAChH,+CAAyD;AAEzD,iDAA4D;AAE5D;;;;;;GAMG;AAII,IAAM,SAAS,GAAf,MAAM,SAAS;CAuCrB,CAAA;AAvCY,8BAAS;AAGX;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,WAAW,EAAE,wCAAwC,EAAE,CAAC;;qCAC1D;AAInB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,uBAAuB,EAAE,CAAC;sCACvE,cAAM;yCAAA;AAGf;IADC,IAAA,oBAAU,EAAC,CAAC,CAAY,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;;2CACtB;AAIjB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,kDAAkD,EAAE,CAAC;;6CACxE;AAInB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,qDAAqD,EAAE,CAAC;;2CAC7E;AAIjB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,sEAAsE,EAAE,CAAC;;4CAC7F;AAIlB;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IACvC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,0DAA0D,EAAE,CAAC;;2CAC/F;AAIjB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,mCAAmC,EAAE,CAAC;;4CAC1D;AAIlB;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC/C,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,oBAAY,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,oEAAoE,EAAE,CAAC;;0CACtH;AAIb;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,qDAAqD,EAAE,CAAC;sCAClF,IAAI;4CAAA;oBAtCL,SAAS;IAHrB,IAAA,gBAAM,GAAE;IACR,IAAA,eAAK,EAAC,iBAAiB,EAAE,CAAC,CAAY,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IACnG,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,2EAA2E,EAAE,CAAC;GAC5F,SAAS,CAuCrB","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@Entity()\n@Index('ix_twin_event_0', (e: TwinEvent) => [e.domain, e.instanceId, e.revision], { 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 @Column({ nullable: true })\n @Field({ nullable: true, description: 'Twin runtime instance id that emitted the event.' })\n instanceId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Kernel tenant id carried on the canonical envelope.' })\n tenantId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, 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 @Column({ nullable: true })\n @Field({ nullable: true, description: 'Simulation event time (ISO 8601).' })\n eventTime?: 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 *\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@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 @Column({ nullable: true })\n @Field({ nullable: true, description: 'Twin runtime instance id that emitted the event.' })\n instanceId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Kernel tenant id carried on the canonical envelope.' })\n tenantId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, 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 @Column({ nullable: true })\n @Field({ nullable: true, description: 'Simulation event time (ISO 8601).' })\n eventTime?: string\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 @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: 'Business transaction identifier (PO / SO), promoted from the payload for indexed lookup of one order history.' })\n orderId?: 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 or mover identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server.' })\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,6 +1,25 @@
1
+ import { ListParam } from '@things-factory/shell';
1
2
  import { TwinEvent } from '../twin-event/twin-event.js';
3
+ import { TwinEventList } from '../twin-event/twin-event-type.js';
2
4
  export declare class TwinJournalQuery {
3
5
  twinEvents(instanceId: string, fromRevision: number, toRevision: number, eventType: string, limit: number, context: ResolverContext, untilTime?: string): Promise<TwinEvent[]>;
6
+ /**
7
+ * 저널 목록 — things-factory 표준 목록 계약(`ListParam` → `{ items, total }`).
8
+ *
9
+ * ── 왜 `twinEvents` 와 따로 두는가 ─────────────────────────────────────────
10
+ * `twinEvents` 는 배열만 돌려준다. 화면은 자기가 받은 게 전부인지 잘린 건지 알 수 없었고, 그래서
11
+ * 리스트가 **조용히 잘린 채 "이게 전부" 처럼** 보였다(원장 limit 120 이 대표적). 총건수를 함께
12
+ * 주면 "48 / 12,904" 라고 말할 수 있고, 사용자는 좁혀야 한다는 사실을 안다.
13
+ * 기존 호출자를 깨지 않으려고 `twinEvents` 는 그대로 두고 목록 계약을 새로 연다.
14
+ *
15
+ * ── 검색이 진짜인 이유 ─────────────────────────────────────────────────────
16
+ * `searchables` 는 전부 **인덱스 가능한 승격 컬럼**이다(payload JSON 안이 아니라). 프레임워크
17
+ * 질의 빌더는 `searchables` 에 없는 컬럼의 LIKE 를 경고 후 무시한다 — 인덱스 없는 전체 스캔을
18
+ * 막기 위해서다. 그 규율에 맞추려고 검색 축을 컬럼으로 승격했다(`twin-event-keys.ts`).
19
+ *
20
+ * 정렬 기본값은 revision DESC(최신순) — 저널의 자연 순서이자 ix_twin_event_0 가 그대로 타는 축.
21
+ */
22
+ twinEventList(params: ListParam, context: ResolverContext, instanceId?: string, spaceId?: string): Promise<TwinEventList>;
4
23
  /**
5
24
  * 업무 KPI — 시간창 처리량·소요시간·자원 점유. 저널을 **접어서** 만든다(새 계측을 심지 않는다).
6
25
  *