@things-factory/headless-twin 10.0.13 → 10.0.14
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/ingest-health.d.ts +41 -0
- package/dist-server/engine/ingest-health.js +48 -0
- package/dist-server/engine/ingest-health.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +34 -0
- package/dist-server/engine/twin-engine.js +180 -6
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +26 -0
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-assessment.d.ts +11 -1
- package/dist-server/service/reference/reference-assessment.js +22 -2
- package/dist-server/service/reference/reference-assessment.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +18 -0
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +12 -2
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +24 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-model/epcis-coverage.js +27 -0
- package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
- package/dist-server/service/twin-model/isa95-coverage.js +85 -5
- package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
- package/dist-server/service/twin-model/item-ref.d.ts +3 -3
- package/dist-server/service/twin-model/item-ref.js +4 -4
- package/dist-server/service/twin-model/item-ref.js.map +1 -1
- package/package.json +3 -3
- package/server/engine/ingest-health.ts +78 -0
- package/server/engine/twin-engine.ts +199 -6
- package/server/service/reference/reference-adapter.ts +23 -0
- package/server/service/reference/reference-assessment.ts +39 -4
- package/server/service/reference/reference-live.ts +20 -0
- package/server/service/reference/reference-master.ts +12 -2
- package/server/service/twin-forecast/twin-forecast-query.ts +27 -1
- package/server/service/twin-model/epcis-coverage.ts +27 -0
- package/server/service/twin-model/isa95-coverage.ts +85 -5
- package/server/service/twin-model/item-ref.ts +4 -4
- package/test/checkpoint-refuses-empty.test.ts +103 -0
- package/test/cursor-stall-not-read-failure.test.ts +126 -0
- package/test/item-ref.test.ts +3 -3
- package/test/mirror-resumes-from-checkpoint.test.ts +192 -0
- package/test/revision-axis.test.ts +13 -2
- package/test/status-tally.test.ts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -26,18 +26,18 @@
|
|
|
26
26
|
* **어느 부호가 개체를 가리키는지 모른다.** 그것은 원본마다 다르고 커널 계약이 정한 적이 없다 — 한
|
|
27
27
|
* 원본(chef)이 쓰는 `@` 를 규칙으로 굳히면 이 화면이 그 레거시 위에 서게 된다. 자르는 판단은 그 트윈의
|
|
28
28
|
* **선언**이 하고(§`resolveByDeclaration`), 후보가 둘 이상이면 답하지 않는다. 짐작으로 이으면 남의
|
|
29
|
-
* 레시피와
|
|
29
|
+
* 레시피와 비교하게 되고, 그것이 「설계와 다르다」는 거짓 진단이 된다.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* 개체 식별자 → **품목 참조**. 대응 표의 값과
|
|
33
|
+
* 개체 식별자 → **품목 참조**. 대응 표의 값과 비교할 수 있는 모양으로 돌려준다.
|
|
34
34
|
*
|
|
35
35
|
* SGTIN 은 표준이 자리를 정해 두었으므로 그 자리에서 품목 참조를 꺼낸다. 그 밖의 모양은 **손대지
|
|
36
36
|
* 않는다** — 어느 부호가 개체를 가리키는지는 원본마다 다르고, 커널 계약이 그 구분자를 정한 적이 없다.
|
|
37
37
|
* 앞서 이 자리에 「`@` 뒤는 개체다」를 넣었는데, 그것은 한 원본(chef)의 모양을 규칙으로 굳힌 것이었다.
|
|
38
38
|
*
|
|
39
|
-
* 그래서 자르는 일은 여기서 하지 않고, **그 트윈의 선언과
|
|
40
|
-
* 선언이 답을 주지 못하면 `null` 이다 — 짐작으로 이으면 남의 레시피와
|
|
39
|
+
* 그래서 자르는 일은 여기서 하지 않고, **그 트윈의 선언과 비교해** 판정한다(§`resolveByDeclaration`).
|
|
40
|
+
* 선언이 답을 주지 못하면 `null` 이다 — 짐작으로 이으면 남의 레시피와 비교하게 되고, 그것이 「설계와
|
|
41
41
|
* 다르다」는 거짓 진단이 된다.
|
|
42
42
|
*/
|
|
43
43
|
export function itemRefOf(epc: unknown): string | null {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** — 체크포인트가 좋은 스냅샷을 덮지 않게.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 손실을 영구화하고 있었나 (2026-08-24 실측) ────────────────────────
|
|
5
|
+
* 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를 버린다 —
|
|
6
|
+
* 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그 정정이 오지
|
|
7
|
+
* 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다.
|
|
8
|
+
*
|
|
9
|
+
* 실측(포천, `operato-chef-v1-pocheon`):
|
|
10
|
+
*
|
|
11
|
+
* 저널을 접으면 items 741 · orders 2,856 · tasks 9,250 · nowTime 2026-04-15
|
|
12
|
+
* 라이브 미러 items 0 · orders 0 · tasks 0 · nowTime 2026-01-01
|
|
13
|
+
* 저장된 스냅샷 rev 221,884 · items 741 · orders 2,780 · tasks 6,353 (어제 것)
|
|
14
|
+
* 그런데 이 판이 같은 키에 **빈 상태**를 쓰고 있었다
|
|
15
|
+
*
|
|
16
|
+
* 그러면 되살릴 캐시가 사라진다. 재기동이 상태를 버리는 것은 저널에서 되살릴 수 있지만, **캐시를
|
|
17
|
+
* 지우는 것은 그 복구 수단을 함께 지운다.**
|
|
18
|
+
*
|
|
19
|
+
* ── 왜 리비전으로는 막을 수 없나 ────────────────────────────────────────────
|
|
20
|
+
* 저널 줄 번호는 **관측이 없어도 자란다**(다른 축·다른 트윈의 사건, 체크포인트 자체). 그래서
|
|
21
|
+
* 「리비전이 더 크면 진전」이라는 규칙은 빈 상태를 통과시킨다.
|
|
22
|
+
*
|
|
23
|
+
* 막는 기준은 **「들은 것이 있나」**다. 유입이 한 건도 없었다면 그 빈 상태는 원본이 「비었다」고
|
|
24
|
+
* 말한 것이 아니라 **우리가 아무것도 못 들은 것**이다 — 그 둘을 같은 값으로 적으면 「모름」이
|
|
25
|
+
* 「없음」이 된다. 이 저장소가 여러 번 거절한 모양이다.
|
|
26
|
+
*
|
|
27
|
+
* 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다.
|
|
28
|
+
*
|
|
29
|
+
* ── 엔진 본체는 부를 수 없다 ────────────────────────────────────────────────
|
|
30
|
+
* DB 를 물고 있어 시험에서 부르지 못한다(§`snapshot-freshness` 와 같은 사정). 그래서 판정식만 떼어
|
|
31
|
+
* 시험하고, **그 식이 엔진에 실제로 있는지**를 소스로 함께 확인한다 — 식만 시험하면 엔진이 그것을
|
|
32
|
+
* 쓰지 않아도 초록이다.
|
|
33
|
+
*/
|
|
34
|
+
import { test } from 'node:test'
|
|
35
|
+
import assert from 'node:assert/strict'
|
|
36
|
+
import { readFileSync } from 'node:fs'
|
|
37
|
+
import { join } from 'node:path'
|
|
38
|
+
|
|
39
|
+
/** 엔진의 조건과 같은 식(`twin-engine.persistSnapshot`). */
|
|
40
|
+
const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
|
|
41
|
+
|
|
42
|
+
const refuses = (live: any, stored: any, ingestedTotal: number): boolean =>
|
|
43
|
+
!(ingestedTotal > 0) && observed(live) === 0 && observed(stored) > 0
|
|
44
|
+
|
|
45
|
+
const RICH = { items: [{ epc: 'a' }], orders: [{ id: 'o' }], tasks: [{ id: 't' }] }
|
|
46
|
+
const EMPTY = { items: [], orders: [], tasks: [] }
|
|
47
|
+
|
|
48
|
+
test('못 들었고 라이브가 비었는데 저장된 것에 사실이 있으면 — **거절한다**', () => {
|
|
49
|
+
/* ★ 이 시험이 막는 것. 통과시키면 되살릴 유일한 사본이 사라진다. */
|
|
50
|
+
assert.equal(refuses(EMPTY, RICH, 0), true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('들은 것이 있으면 통과한다 — 원본이 「다 비었다」고 말한 것은 사실이다', () => {
|
|
54
|
+
/*
|
|
55
|
+
* 창고가 실제로 비는 것은 정상이다. 그때는 유입이 있었으므로 그 빈 상태가 **관측**이고,
|
|
56
|
+
* 여기서 막으면 스냅샷이 영구히 낡는다(고치려던 것의 반대 방향으로 틀린다).
|
|
57
|
+
*/
|
|
58
|
+
assert.equal(refuses(EMPTY, RICH, 1), false)
|
|
59
|
+
assert.equal(refuses(EMPTY, RICH, 24839), false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('라이브에 사실이 있으면 통과한다 — 못 들었어도 심어진 상태는 지킬 값이다', () => {
|
|
63
|
+
assert.equal(refuses(RICH, RICH, 0), false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('저장된 것이 비어 있으면 막을 것이 없다 — 첫 체크포인트를 가로막지 않는다', () => {
|
|
67
|
+
/* 처음 뜬 트윈은 저장된 것이 없다. 여기서 막으면 그 트윈은 영원히 스냅샷을 갖지 못한다. */
|
|
68
|
+
assert.equal(refuses(EMPTY, EMPTY, 0), false)
|
|
69
|
+
assert.equal(refuses(EMPTY, null, 0), false)
|
|
70
|
+
assert.equal(refuses(EMPTY, undefined, 0), false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('축 하나만 남아도 사실이다 — 셋을 합쳐 센다', () => {
|
|
74
|
+
/*
|
|
75
|
+
* 오더만 남고 재고가 빈 상태는 흔하다(다 내보낸 창고). 축마다 따로 판정하면 그 상태가
|
|
76
|
+
* 「비었다」로 읽힌다.
|
|
77
|
+
*/
|
|
78
|
+
assert.equal(refuses(EMPTY, { items: [], orders: [{ id: 'o' }], tasks: [] }, 0), true)
|
|
79
|
+
assert.equal(refuses(EMPTY, { items: [], orders: [], tasks: [{ id: 't' }] }, 0), true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('리비전은 판정에 쓰지 않는다 — 저널 번호는 관측이 없어도 자란다', () => {
|
|
83
|
+
/*
|
|
84
|
+
* ★ 이 단정이 **틀린 기준으로 돌아가는 것**을 막는다. 실측에서 저장된 스냅샷의 리비전은 221,884
|
|
85
|
+
* 였고 라이브는 그보다 큰 번호를 들고 있었다 — 리비전을 기준으로 삼으면 빈 상태가 통과한다.
|
|
86
|
+
*/
|
|
87
|
+
const live = { ...EMPTY, revision: 999999 }
|
|
88
|
+
const stored = { ...RICH, revision: 1 }
|
|
89
|
+
assert.equal(refuses(live, stored, 0), true, '번호가 커도 사실이 없으면 덮지 않는다')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('엔진이 실제로 이 판정을 한다 — 식만 시험하면 엔진이 안 써도 초록이다', () => {
|
|
93
|
+
const src = readFileSync(join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts'), 'utf8')
|
|
94
|
+
/* 주석을 걷어낸 코드만 — 「덮지 않는다」를 적어 둔 근거가 가드를 뒤집지 않게. */
|
|
95
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
96
|
+
const at = code.indexOf('persistSnapshot')
|
|
97
|
+
assert.ok(at > 0, 'persistSnapshot 이 있어야 한다')
|
|
98
|
+
const body = code.slice(at, at + 2000)
|
|
99
|
+
assert.match(body, /ingestedTotal/, '「들은 것이 있나」를 본다')
|
|
100
|
+
assert.match(body, /items\?\.length[\s\S]{0,80}orders\?\.length[\s\S]{0,80}tasks\?\.length/, '관측 축 셋을 합쳐 센다')
|
|
101
|
+
assert.match(body, /checkpoint refused/, '거절을 말한다 — 조용히 거절하지 않는다')
|
|
102
|
+
assert.doesNotMatch(body.slice(0, body.indexOf('checkpoint refused')), /revision >|revision </, '리비전 비교로 판정하지 않는다')
|
|
103
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.**
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 반대 방향을 가리켰나 (2026-08-24) ────────────────────────────────
|
|
5
|
+
* 2026-08-23 에 「원본에 닿지 못한다」를 트윈이 말할 수 있게 `onReadFailure` 를 만들었다. 그런데 그 문으로
|
|
6
|
+
* **다른 사실**이 함께 나가고 있었다.
|
|
7
|
+
*
|
|
8
|
+
* 원본에 닿지 못했다 접속 실패·시간 초과 → **기다리면 풀린다**
|
|
9
|
+
* 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행 → **기다려도 안 풀린다**
|
|
10
|
+
*
|
|
11
|
+
* 둘째는 **읽기가 성공한 실패**다. 원본은 답했고 그 답의 모양이 커서를 이겼다. 그런데 화면이
|
|
12
|
+
* 「원본에 닿지 못한다」고 말하면 사람은 원본을 의심하고 기다린다 — 실제로 필요한 조치는 페이지를
|
|
13
|
+
* 키우거나 같은 시각 안에서 순서를 정하는 것이고, 기다림으로는 영원히 풀리지 않는다.
|
|
14
|
+
*
|
|
15
|
+
* 게다가 그 문구가 스트림 이름을 꺼내는 규칙에 맞지 않아 **어느 표가 막혔는지도 남지 않았다.**
|
|
16
|
+
*
|
|
17
|
+
* ── 이 시험이 지키는 것 ─────────────────────────────────────────────────────
|
|
18
|
+
* ① 두 축이 **서로를 지우지 않는다** ② 두 축이 **각자 뷰로 나간다**(유입이 없는 갈래에서도)
|
|
19
|
+
* ③ 「몇 번·언제부터」를 정체 쪽도 든다 ④ 넘어가면 지운다
|
|
20
|
+
*/
|
|
21
|
+
import { test } from 'node:test'
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import {
|
|
26
|
+
clearCursorStall,
|
|
27
|
+
clearReadFailure,
|
|
28
|
+
ingestHealth,
|
|
29
|
+
newIngestLedger,
|
|
30
|
+
recordCursorStall,
|
|
31
|
+
recordIngest,
|
|
32
|
+
recordReadFailure
|
|
33
|
+
} from '../server/engine/ingest-health.ts'
|
|
34
|
+
|
|
35
|
+
const T0 = Date.parse('2026-08-24T06:00:00.000Z')
|
|
36
|
+
|
|
37
|
+
test('정체는 읽기 실패와 **다른 칸**이다 — 서로를 지우지 않는다', () => {
|
|
38
|
+
/*
|
|
39
|
+
* ★ 한 칸을 쓰면 나중에 온 것이 앞의 것을 덮는다. 그러면 「닿지 못한다」와 「못 넘어간다」 중
|
|
40
|
+
* 마지막 것만 남고, 화면은 조치를 반대로 안내할 수 있다.
|
|
41
|
+
*/
|
|
42
|
+
const l = newIngestLedger()
|
|
43
|
+
recordReadFailure(l, 'connect ETIMEDOUT', T0, 'inventory')
|
|
44
|
+
recordCursorStall(l, 'one timestamp holds more rows than a page', T0 + 1000, 'trace')
|
|
45
|
+
assert.equal(l.readFailure?.reason, 'connect ETIMEDOUT', '읽기 실패가 남아 있다')
|
|
46
|
+
assert.equal(l.cursorStall?.reason, 'one timestamp holds more rows than a page')
|
|
47
|
+
assert.equal(l.readFailure?.stream, 'inventory')
|
|
48
|
+
assert.equal(l.cursorStall?.stream, 'trace', '어느 표가 막혔나가 조치의 절반이다')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('각자 지워진다 — 하나가 풀렸다고 다른 하나를 지우지 않는다', () => {
|
|
52
|
+
const l = newIngestLedger()
|
|
53
|
+
recordReadFailure(l, 'ETIMEDOUT', T0)
|
|
54
|
+
recordCursorStall(l, 'cursor cannot advance', T0)
|
|
55
|
+
clearReadFailure(l)
|
|
56
|
+
assert.equal(l.readFailure, undefined, '다시 닿았다')
|
|
57
|
+
assert.ok(l.cursorStall, '그렇다고 커서가 넘어간 것은 아니다')
|
|
58
|
+
clearCursorStall(l)
|
|
59
|
+
assert.equal(l.cursorStall, undefined, '넘어갔으면 지운다')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('「몇 번·언제부터」를 정체 쪽도 든다 — 한 번 밀린 것과 사흘째는 다르다', () => {
|
|
63
|
+
const l = newIngestLedger()
|
|
64
|
+
recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0)
|
|
65
|
+
recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 600_000)
|
|
66
|
+
recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 1_200_000)
|
|
67
|
+
assert.equal(l.cursorStall?.consecutive, 3)
|
|
68
|
+
assert.equal(l.cursorStall?.sinceMs, T0, '처음 막힌 시각을 지킨다')
|
|
69
|
+
assert.equal(l.cursorStall?.lastAtMs, T0 + 1_200_000)
|
|
70
|
+
/* 넘어간 뒤 다시 막히면 **새 정체**다 — 옛 시작 시각을 물려받지 않는다. */
|
|
71
|
+
clearCursorStall(l)
|
|
72
|
+
recordCursorStall(l, 'stuck again', T0 + 3_600_000)
|
|
73
|
+
assert.equal(l.cursorStall?.consecutive, 1)
|
|
74
|
+
assert.equal(l.cursorStall?.sinceMs, T0 + 3_600_000)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('뷰로 나간다 — **유입이 없는 갈래에서도**', () => {
|
|
78
|
+
/*
|
|
79
|
+
* ★ 여기가 가장 조용해지기 쉬운 자리다. 커서가 막히면 유입이 0이므로 장부가 「빈 장부」 갈래를
|
|
80
|
+
* 지나고, 그 갈래에서 이 값을 떨어뜨리면 **정체가 실제로 일어나는 바로 그 상황에서** 침묵한다.
|
|
81
|
+
* `unhandled` 를 스냅샷이 떨어뜨려 정합성 검사가 영원히 조용했던 것과 같은 부류다.
|
|
82
|
+
*/
|
|
83
|
+
const l = newIngestLedger()
|
|
84
|
+
recordCursorStall(l, 'one timestamp holds more rows than a page', T0, 'trace')
|
|
85
|
+
const v = ingestHealth(l, 'attached', undefined, T0 + 60_000)
|
|
86
|
+
assert.ok(v.cursorStall, '유입이 없어도 뷰에 실린다')
|
|
87
|
+
assert.equal(v.cursorStall?.consecutive, 1)
|
|
88
|
+
assert.equal(v.cursorStall?.since, new Date(T0).toISOString())
|
|
89
|
+
assert.equal(v.cursorStall?.stream, 'trace')
|
|
90
|
+
assert.equal(v.readFailure, undefined, '닿지 못한 것은 아니다 — 섞이지 않는다')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('유입이 있는 갈래에서도 나간다 — 두 갈래를 다 채운다', () => {
|
|
94
|
+
/* 갈래가 둘인 함수에서 한쪽만 채우는 실수를 이 저장소가 이미 겪었다. */
|
|
95
|
+
const l = newIngestLedger()
|
|
96
|
+
recordIngest(l, 5, [], T0)
|
|
97
|
+
recordCursorStall(l, 'cursor cannot advance', T0 + 1000, 'lot')
|
|
98
|
+
const v = ingestHealth(l, 'attached', undefined, T0 + 2000)
|
|
99
|
+
assert.equal(v.total.offered, 5, '유입이 있는 갈래다 — 빈 비교로 통과하지 않는다')
|
|
100
|
+
assert.equal(v.cursorStall?.stream, 'lot')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('막히지 않았으면 그 칸을 만들지 않는다 — 빈 값이 「막혔다」로 읽히지 않게', () => {
|
|
104
|
+
const l = newIngestLedger()
|
|
105
|
+
recordIngest(l, 3, [], T0)
|
|
106
|
+
assert.equal(ingestHealth(l, 'attached', undefined, T0 + 1000).cursorStall, undefined)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('두 사실이 같은 문으로 나가지 않는다 — 배선을 소스로 확인한다', () => {
|
|
110
|
+
/*
|
|
111
|
+
* 장부만 시험하면 **호스트가 여전히 읽기 실패 문으로 보내도** 초록이다. 실제로 그랬던 것이 이 결함의
|
|
112
|
+
* 시작이었다.
|
|
113
|
+
*/
|
|
114
|
+
const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8')
|
|
115
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
116
|
+
assert.match(code, /onCursorStall:/, '어댑터의 정체 통로를 잇는다')
|
|
117
|
+
assert.match(code, /recordIngestCursorStall/, '정체는 정체 장부로 간다')
|
|
118
|
+
/* 그리고 그 문구가 「닿지 못한다」와 달라야 한다 — 같은 말이면 화면이 여전히 반대로 안내한다. */
|
|
119
|
+
const at = code.indexOf('onCursorStall:')
|
|
120
|
+
const body = code.slice(at, at + 900)
|
|
121
|
+
assert.match(body, /cursor cannot advance/, '무엇이 막혔는지 말한다')
|
|
122
|
+
assert.match(body, /Waiting will not clear this/, '기다림이 답이 아니라고 말한다')
|
|
123
|
+
assert.doesNotMatch(body, /recordIngestReadFailure/, '읽기 실패 장부로 보내지 않는다')
|
|
124
|
+
/* 재부착의 자격(`status`)은 여기서도 건드리지 않는다 — 2026-08-23 에 되돌린 그 규율. */
|
|
125
|
+
assert.doesNotMatch(body, /status:/, '정체가 피드를 영구히 끄지 않는다')
|
|
126
|
+
})
|
package/test/item-ref.test.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* ── 그리고 고치는 방향을 한 번 더 고쳤다 ────────────────────────────────────
|
|
9
9
|
* 처음에는 「`@` 뒤는 개체다」를 규칙으로 넣었다. 그것은 **한 원본(chef)의 관행**이고, 커널 계약이 그
|
|
10
10
|
* 구분자를 정한 적이 없다 — 사용자가 「이 화면이 특정 레거시를 기준으로 해서는 안 된다」고 짚었다.
|
|
11
|
-
* 그래서 자르는 판단을 걷고, **그 트윈의 선언과
|
|
11
|
+
* 그래서 자르는 판단을 걷고, **그 트윈의 선언과 비교해** 답한다.
|
|
12
12
|
*
|
|
13
13
|
* 이 시험이 지키는 것:
|
|
14
14
|
* ① SGTIN 은 표준이 정한 자리에서 품목 참조를 꺼낸다.
|
|
15
15
|
* ② 그 밖의 모양은 **손대지 않는다**(구분자를 모른다).
|
|
16
|
-
* ③ 선언과
|
|
16
|
+
* ③ 선언과 비교할 때는 두 방향을 본다 — 관측이 선언을 확장한 경우, 화면이 짧게 줄인 경우.
|
|
17
17
|
* ④ 후보가 둘 이상이면 **답하지 않는다**(모르는 것이 틀린 답보다 낫다).
|
|
18
18
|
*/
|
|
19
19
|
import { test } from 'node:test'
|
|
@@ -64,7 +64,7 @@ test('화면이 짧게 줄인 경우 — 선언이 그것으로 끝난다', () =
|
|
|
64
64
|
assert.equal(resolveByDeclaration('product-M0000438', DECLARED), 'M0000438')
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
-
test('후보가 둘 이상이면 답하지 않는다 — 남의 레시피와
|
|
67
|
+
test('후보가 둘 이상이면 답하지 않는다 — 남의 레시피와 비교하지 않게', () => {
|
|
68
68
|
const ambiguous = new Map<string, string>([
|
|
69
69
|
['https://a.net/class/product-1', 'A1'],
|
|
70
70
|
['https://b.net/class/product-1', 'B1']
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **재기동이 저널을 처음부터 다시 접지 않는다** — 미러가 저장된 재개점에서 이어 선다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 오래 잘못돼 있었나 (2026-08-24 실측) ─────────────────────────────
|
|
5
|
+
* 재개점을 **읽는** 쪽은 다 있었다: `recover` 는 `fold` 가 있으면 꼬리만 접고, 없으면 저널을 0부터
|
|
6
|
+
* 접는다. 그런데 **쓰는 쪽이 없었다** — `persistSnapshot` 은 `{revision, state, structureRev}` 만
|
|
7
|
+
* 적었다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 두 가지가 함께 일어났다.
|
|
10
|
+
*
|
|
11
|
+
* ① 켤 때마다 처음부터 접었다 — 규모 기준(엔티티 10만·품목 100만)에서 느린 것이 아니라 못 하는 것이다
|
|
12
|
+
* ② 미러는 되찾은 상태를 **버렸다**(`startLive`). 「다음 계측이 정정한다」는 전제였는데, 원본이 커서
|
|
13
|
+
* 증분으로 말하는 현장에서는 그 정정이 오지 않는다 — 미러가 영구히 빈 채로 남고 화면은 조용했다
|
|
14
|
+
*
|
|
15
|
+
* ── 왜 커널 시험만으로 충분하지 않나 ────────────────────────────────────────
|
|
16
|
+
* 커널은 「재개점 + 꼬리 == 0부터 접기」를 증명한다(`observed-checkpoint.test.ts`). 그것이 참이어도
|
|
17
|
+
* 호스트가 재개점을 **적지 않거나 되세우지 않으면** 아무 일도 일어나지 않는다 — 이 저장소가 실제로
|
|
18
|
+
* 넉 달 동안 그 상태였다. 그래서 여기서 DB 를 물고 실제로 걷는다.
|
|
19
|
+
*
|
|
20
|
+
* ── 결정적인 단정 ───────────────────────────────────────────────────────────
|
|
21
|
+
* 재기동 전에 **저널을 지운다.** 그래도 상태가 돌아오면 그것은 저널을 접어 온 것이 **아니다** —
|
|
22
|
+
* 저장된 재개점에서 온 것이다. 그리고 그 상황은 가정이 아니라 설계된 거동이다: 보존 정책이 체크포인트
|
|
23
|
+
* 리비전 이하의 행을 지운다(「그 앞은 스냅샷이 대신한다」).
|
|
24
|
+
*/
|
|
25
|
+
import { test, before, after } from 'node:test'
|
|
26
|
+
import assert from 'node:assert/strict'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import type { DataSource } from 'typeorm'
|
|
29
|
+
|
|
30
|
+
const req = createRequire(import.meta.url)
|
|
31
|
+
const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
|
|
32
|
+
const { entities: authEntities } = req('@things-factory/auth-base')
|
|
33
|
+
const { entities: twinEntities } = req('../dist-server/service/index.js')
|
|
34
|
+
/* 체크포인트는 `cache-service` 표에 앉는다 — 그 엔티티를 빼면 저장이 조용히 실패한다(스냅샷도 씨앗도). */
|
|
35
|
+
const { entities: cacheEntities } = req('@things-factory/cache-service/dist-server/service/index.js')
|
|
36
|
+
const { TwinEngine } = req('../dist-server/engine/index.js')
|
|
37
|
+
const { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js')
|
|
38
|
+
const { DataSource: TypeOrmDataSource } = req('typeorm')
|
|
39
|
+
|
|
40
|
+
let ds: DataSource
|
|
41
|
+
let domainId: string
|
|
42
|
+
|
|
43
|
+
const INSTANCE = 'resume-probe'
|
|
44
|
+
|
|
45
|
+
/** 미러 하나 — 원본이 사실을 말해 주는 트윈이므로 시나리오도 레시피도 필요 없다. */
|
|
46
|
+
const master = (): any => ({
|
|
47
|
+
source: INSTANCE,
|
|
48
|
+
system: 'wms',
|
|
49
|
+
siteName: '재개점 창고',
|
|
50
|
+
origin: { kind: 'reference' }, // 현실 출처 선언 — 이것이 미러로 뜨는 근거다
|
|
51
|
+
space: { width: 40, depth: 20, unit: 'm' },
|
|
52
|
+
locations: [
|
|
53
|
+
{ id: 'dock', type: 'dock', role: 'location', capacity: 100 },
|
|
54
|
+
{ id: 'bin-1', type: 'storage', role: 'location', capacity: 100 }
|
|
55
|
+
],
|
|
56
|
+
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock' }]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** 원본이 말한 사실 — 오더와 작업. 미러는 이것을 접어 상태를 만든다. */
|
|
60
|
+
function facts(n: number): any[] {
|
|
61
|
+
const out: any[] = []
|
|
62
|
+
for (let i = 1; i <= n; i++) {
|
|
63
|
+
out.push({
|
|
64
|
+
eventId: `o-${i}`,
|
|
65
|
+
eventType: 'order.status',
|
|
66
|
+
eventTime: '2026-08-24T01:00:00.000Z',
|
|
67
|
+
tenantId: domainId,
|
|
68
|
+
data: { orderId: `WO-${i}`, kind: 'workorder', status: 'in-progress', requested: 10, fulfilled: 3 }
|
|
69
|
+
})
|
|
70
|
+
out.push({
|
|
71
|
+
eventId: `t-${i}`,
|
|
72
|
+
eventType: 'task.status',
|
|
73
|
+
eventTime: '2026-08-24T01:00:01.000Z',
|
|
74
|
+
tenantId: domainId,
|
|
75
|
+
data: {
|
|
76
|
+
taskId: `TK-${i}`,
|
|
77
|
+
kind: 'putaway',
|
|
78
|
+
status: 'in-progress',
|
|
79
|
+
orderId: `WO-${i}`,
|
|
80
|
+
fromNode: 'dock',
|
|
81
|
+
toNode: 'bin-1',
|
|
82
|
+
intent: 'transport',
|
|
83
|
+
durationMs: 60000,
|
|
84
|
+
remainingMs: 30000
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
|
|
92
|
+
|
|
93
|
+
before(async () => {
|
|
94
|
+
ds = new TypeOrmDataSource({
|
|
95
|
+
type: 'sqlite',
|
|
96
|
+
database: ':memory:',
|
|
97
|
+
synchronize: true,
|
|
98
|
+
dropSchema: true,
|
|
99
|
+
namingStrategy: new NamingStrategy(),
|
|
100
|
+
entities: [...shellEntities, ...authEntities, ...twinEntities, ...cacheEntities],
|
|
101
|
+
logging: false
|
|
102
|
+
})
|
|
103
|
+
await ds.initialize()
|
|
104
|
+
addDataSource('default', ds)
|
|
105
|
+
const d = await ds.getRepository(Domain).save({ name: 'resume-test', subdomain: 'resume-test' } as any)
|
|
106
|
+
domainId = d.id
|
|
107
|
+
await TwinEngine.ingestMaster(domainId, master(), 'reset')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
after(async () => {
|
|
111
|
+
await TwinEngine.stop(domainId, INSTANCE).catch(() => {})
|
|
112
|
+
await ds?.destroy()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('체크포인트가 **이어 접을 씨앗을 함께 적는다** — 이 줄이 없어서 넉 달 동안 처음부터 접었다', async () => {
|
|
116
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync')
|
|
117
|
+
assert.equal(rt.mode, 'live', '미러로 떠야 이 검사가 뜻이 있다')
|
|
118
|
+
|
|
119
|
+
const applied = TwinEngine.ingestLive(domainId, INSTANCE, facts(5))
|
|
120
|
+
assert.equal(applied, 10, '원본이 말한 사실이 커널에 들어갔다')
|
|
121
|
+
const before = TwinEngine.snapshot(domainId, INSTANCE)
|
|
122
|
+
assert.ok(observed(before) > 0, `상태에 사실이 있어야 이 검사가 뜻이 있다 (실제 ${observed(before)})`)
|
|
123
|
+
|
|
124
|
+
await TwinEngine.persistSnapshot(domainId, INSTANCE)
|
|
125
|
+
const stored = await TwinEngine.loadSnapshot(domainId, INSTANCE)
|
|
126
|
+
assert.ok(stored, '스냅샷이 저장됐다')
|
|
127
|
+
assert.ok(stored.fold?.reducer, '★ 재개점이 함께 적혀야 한다 — 없으면 다음 기동이 저널을 0부터 접는다')
|
|
128
|
+
assert.ok(stored.fold?.oee, '가동 누적기도 함께 — 상태만 되돌리면 가동률이 부팅 이후로만 잡힌다')
|
|
129
|
+
/* 씨앗이 실제로 사실을 들었는지 — 빈 껍데기를 적어 두고 초록이 되지 않게. */
|
|
130
|
+
assert.ok((stored.fold.reducer as any).orders?.length, '씨앗에 오더가 담겨 있다')
|
|
131
|
+
assert.ok((stored.fold.reducer as any).tasks?.length, '씨앗에 작업이 담겨 있다')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('★ 저널을 지워도 미러가 상태를 들고 다시 선다 — 그러므로 저널을 접은 것이 아니다', async () => {
|
|
135
|
+
/*
|
|
136
|
+
* 이 단정이 이 축의 전부다. 저널이 없는데 상태가 돌아오면 그 상태는 **저장된 재개점**에서 온 것이다.
|
|
137
|
+
* 그리고 이 상황은 가정이 아니다 — 보존 정책이 체크포인트 리비전 이하의 행을 실제로 지운다.
|
|
138
|
+
*/
|
|
139
|
+
const before = observed(TwinEngine.snapshot(domainId, INSTANCE))
|
|
140
|
+
assert.ok(before > 0, '멈추기 전에 상태가 있어야 이 검사가 뜻이 있다')
|
|
141
|
+
|
|
142
|
+
await TwinEngine.stop(domainId, INSTANCE)
|
|
143
|
+
/* 이 인스턴스의 저널만 — 정확한 소유 표식으로 지운다. */
|
|
144
|
+
await ds.getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId: INSTANCE } as any)
|
|
145
|
+
assert.equal(
|
|
146
|
+
await ds.getRepository(TwinEvent).countBy({ domain: { id: domainId } as any, instanceId: INSTANCE } as any),
|
|
147
|
+
0,
|
|
148
|
+
'저널이 비었다 — 이제 접을 것이 없다'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync')
|
|
152
|
+
const after = TwinEngine.snapshot(domainId, INSTANCE)
|
|
153
|
+
assert.equal(observed(after), before, '★ 사실이 그대로 돌아왔다 — 저널 없이')
|
|
154
|
+
assert.equal(after.orders.length, 5, '오더 다섯이 그대로다')
|
|
155
|
+
assert.equal(after.tasks.length, 5, '작업 다섯이 그대로다')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('되세운 뒤 들어온 사실이 앞의 것을 덮지 않는다 — 더해진다', async () => {
|
|
159
|
+
/*
|
|
160
|
+
* 미러는 첫 봉투가 올 때 리듀서를 만든다. 되돌리기가 그보다 먼저 서지 않으면 첫 봉투가 **빈 리듀서**를
|
|
161
|
+
* 만들고 되세운 것을 덮는다 — 오류 없이 상태가 사라진다.
|
|
162
|
+
*/
|
|
163
|
+
const before = observed(TwinEngine.snapshot(domainId, INSTANCE))
|
|
164
|
+
TwinEngine.ingestLive(domainId, INSTANCE, [
|
|
165
|
+
{
|
|
166
|
+
eventId: 'o-new',
|
|
167
|
+
eventType: 'order.status',
|
|
168
|
+
eventTime: '2026-08-24T02:00:00.000Z',
|
|
169
|
+
tenantId: domainId,
|
|
170
|
+
data: { orderId: 'WO-NEW', kind: 'workorder', status: 'in-progress', requested: 1, fulfilled: 0 }
|
|
171
|
+
} as any
|
|
172
|
+
])
|
|
173
|
+
const after = TwinEngine.snapshot(domainId, INSTANCE)
|
|
174
|
+
assert.equal(observed(after), before + 1, '앞의 것이 남고 새 것이 더해진다')
|
|
175
|
+
assert.ok(after.orders.some((o: any) => o.id === 'WO-1'), '되세운 오더가 남아 있다')
|
|
176
|
+
assert.ok(after.orders.some((o: any) => o.id === 'WO-NEW'), '새 오더도 있다')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('씨앗이 없으면 지어내지 않는다 — 전과 같이 빈 채로 시작한다', async () => {
|
|
180
|
+
/*
|
|
181
|
+
* 처음 뜨는 트윈에는 씨앗이 없다. 여기서 무엇이든 심으면 없던 재고가 생긴다 — 그것은 이 작업이
|
|
182
|
+
* 고치려던 것보다 나쁘다.
|
|
183
|
+
*/
|
|
184
|
+
const FRESH = 'resume-probe-fresh'
|
|
185
|
+
await TwinEngine.ingestMaster(domainId, { ...master(), source: FRESH }, 'reset')
|
|
186
|
+
try {
|
|
187
|
+
await TwinEngine.bootDeclared(domainId, FRESH, 'resync')
|
|
188
|
+
assert.equal(observed(TwinEngine.snapshot(domainId, FRESH)), 0, '아무것도 듣지 못했으므로 비어 있다')
|
|
189
|
+
} finally {
|
|
190
|
+
await TwinEngine.stop(domainId, FRESH).catch(() => {})
|
|
191
|
+
}
|
|
192
|
+
})
|
|
@@ -87,7 +87,18 @@ test('체크포인트는 두 수를 나란히 적는다 — 그 사실 자체는
|
|
|
87
87
|
*/
|
|
88
88
|
const at = src.indexOf('static async persistSnapshot')
|
|
89
89
|
assert.ok(at > 0, 'persistSnapshot 이 있어야 한다')
|
|
90
|
-
|
|
90
|
+
/*
|
|
91
|
+
* 창을 넓혀 왔다 — 이 함수 앞쪽에 「빈 상태로 좋은 스냅샷을 덮지 않는다」 가드가 들어오고(§
|
|
92
|
+
* `checkpoint-refuses-empty`), 뒤쪽에 「이어 접을 씨앗을 함께 적는다」가 들어왔다(§
|
|
93
|
+
* `mirror-resumes-from-checkpoint`). 검사하는 **사실은 그대로**다.
|
|
94
|
+
*
|
|
95
|
+
* 그리고 **줄 모양이 아니라 두 자리를 검사한다**: 예전에는 `{ revision, state` 라는 한 줄 모양을
|
|
96
|
+
* 봤는데, 저장 인자가 여러 줄로 나뉘자 사실이 그대로인데도 빨강이 됐다. 서식이 검사를 깨면 다음
|
|
97
|
+
* 사람이 검사를 지우게 된다 — 그래서 이름 둘이 함께 실리는지만 본다.
|
|
98
|
+
*/
|
|
99
|
+
const body = src.slice(at, at + 5000)
|
|
91
100
|
assert.match(body, /inst\.revision \?\? state\.revision/, '바깥은 저널 줄 번호를 먼저 쓴다')
|
|
92
|
-
|
|
101
|
+
const call = body.slice(body.indexOf('setInCache'))
|
|
102
|
+
assert.match(call, /revision/, '저널 줄 번호를 싣는다')
|
|
103
|
+
assert.match(call, /state/, '커널이 접은 상태를 함께 싣는다 — 두 자리를 하나로 합치지 않는다')
|
|
93
104
|
})
|
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
* 수를 줄여 맞는 척하게 만들지 않고, 무엇으로 이루어진 수인지 함께 낸다.
|
|
7
7
|
*
|
|
8
8
|
* 이 시험이 지키는 것:
|
|
9
|
-
* ① 상태별로
|
|
9
|
+
* ① 상태별로 구분해 세고, **많은 것부터** 낸다(카드가 한 줄로 보이므로 순서가 뜻을 만든다).
|
|
10
10
|
* ② 낱말은 커널의 것 그대로 — 여기서 묶거나 고쳐 부르지 않는다.
|
|
11
|
-
* ③ 상태를 말하지 않는 축은 **필드를 만들지 않는다**(빈 표는 「상태 없음」과 「못 읽음」을
|
|
11
|
+
* ③ 상태를 말하지 않는 축은 **필드를 만들지 않는다**(빈 표는 「상태 없음」과 「못 읽음」을 구분하지 못한다).
|
|
12
12
|
*/
|
|
13
13
|
import { test } from 'node:test'
|
|
14
14
|
import assert from 'node:assert/strict'
|
|
15
15
|
|
|
16
16
|
import { statusTally } from '../server/service/twin-model/status-tally.ts'
|
|
17
17
|
|
|
18
|
-
test('상태별로
|
|
18
|
+
test('상태별로 구분해 세고 많은 것부터 낸다', () => {
|
|
19
19
|
const r = statusTally([
|
|
20
20
|
{ id: 'a', status: 'completed' },
|
|
21
21
|
{ id: 'b', status: 'in-progress' },
|